diff --git a/docker/app-mysql/docker-compose.yml b/docker/app-mysql/docker-compose.yml index 1cecbb8d4..e58037576 100644 --- a/docker/app-mysql/docker-compose.yml +++ b/docker/app-mysql/docker-compose.yml @@ -4,7 +4,7 @@ networks: driver: bridge services: app: - image: nocobase/nocobase:0.7.4-alpha.7 + image: nocobase/nocobase:0.7.5-alpha.1 networks: - nocobase depends_on: @@ -15,7 +15,7 @@ services: - DB_DATABASE=nocobase - DB_USER=nocobase - DB_PASSWORD=nocobase - - LOCAL_STORAGE_BASE_URL=http://localhost:13000/storage/uploads + - LOCAL_STORAGE_BASE_URL=/storage/uploads volumes: - ./storage:/app/nocobase/storage ports: diff --git a/docker/app-postgres/docker-compose.yml b/docker/app-postgres/docker-compose.yml index f8217d529..5a98e8746 100644 --- a/docker/app-postgres/docker-compose.yml +++ b/docker/app-postgres/docker-compose.yml @@ -4,7 +4,7 @@ networks: driver: bridge services: app: - image: nocobase/nocobase:0.7.4-alpha.7 + image: nocobase/nocobase:0.7.5-alpha.1 networks: - nocobase environment: @@ -13,7 +13,7 @@ services: - DB_DATABASE=nocobase - DB_USER=nocobase - DB_PASSWORD=nocobase - - LOCAL_STORAGE_BASE_URL=http://localhost:13000/storage/uploads + - LOCAL_STORAGE_BASE_URL=/storage/uploads volumes: - ./storage:/app/nocobase/storage ports: diff --git a/docker/app-sqlite/docker-compose.yml b/docker/app-sqlite/docker-compose.yml index e75bbd9ce..944dcb988 100644 --- a/docker/app-sqlite/docker-compose.yml +++ b/docker/app-sqlite/docker-compose.yml @@ -4,11 +4,11 @@ networks: driver: bridge services: app: - image: nocobase/nocobase:0.7.4-alpha.7 + image: nocobase/nocobase:0.7.5-alpha.1 networks: - nocobase environment: - - LOCAL_STORAGE_BASE_URL=http://localhost:13000/storage/uploads + - LOCAL_STORAGE_BASE_URL=/storage/uploads volumes: - ./storage:/app/nocobase/storage ports: diff --git a/packages/app/client/src/plugins/import.ts b/packages/app/client/src/plugins/import.ts new file mode 100644 index 000000000..b0fdcee94 --- /dev/null +++ b/packages/app/client/src/plugins/import.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-import/client'; \ No newline at end of file diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index f5b815cab..1fdb0bb48 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -1,15 +1,17 @@ import { useField, useFieldSchema, useForm } from '@formily/react'; import { message, Modal } from 'antd'; import parse from 'json-templates'; +import { cloneDeep } from 'lodash'; import get from 'lodash/get'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { useReactToPrint } from 'react-to-print'; -import { useFormBlockContext } from '../..'; +import { useFormBlockContext, useTableBlockContext } from '../..'; import { useAPIClient } from '../../api-client'; import { useCollection } from '../../collection-manager'; import { useRecord } from '../../record-provider'; import { useActionContext, useCompile } from '../../schema-component'; +import { BulkEditFormItemValueType } from '../../schema-initializer/components'; import { useCurrentUserContext } from '../../user'; import { useBlockRequestContext, useFilterByTk } from '../BlockProvider'; import { useDetailsBlockContext } from '../DetailsBlockProvider'; @@ -231,6 +233,173 @@ export const useCustomizeUpdateActionProps = () => { }; }; +export const useCustomizeBulkUpdateActionProps = () => { + const { field, resource, __parent, service } = useBlockRequestContext(); + const actionSchema = useFieldSchema(); + const currentRecord = useRecord(); + const tableBlockContext = useTableBlockContext(); + const { rowKey } = tableBlockContext; + const { selectedRowKeys } = tableBlockContext.field?.data ?? {}; + const currentUserContext = useCurrentUserContext(); + const currentUser = currentUserContext?.data?.data; + const history = useHistory(); + const compile = useCompile(); + const { t } = useTranslation(); + const actionField = useField(); + + return { + async onClick() { + const { + assignedValues: originalAssignedValues = {}, + onSuccess, + updateMode, + } = actionSchema?.['x-action-settings'] ?? {}; + actionField.data = field.data || {}; + actionField.data.loading = true; + const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentUser }); + Modal.confirm({ + title: t('Bulk update'), + content: updateMode === 'selected' ? t('Update selected data?') : t('Update all data?'), + async onOk() { + const { filter } = service.params?.[0] ?? {}; + const updateData: { filter?: any; values: any; forceUpdate: boolean } = { + values: { ...assignedValues }, + filter, + forceUpdate: false, + }; + if (updateMode === 'selected') { + if (!selectedRowKeys?.length) { + message.error(t('Please select the records to be updated')); + actionField.data.loading = false; + return; + } + updateData.filter = { $and: [{ [rowKey || 'id']: { $in: selectedRowKeys } }] }; + } + if (!updateData.filter) { + updateData.forceUpdate = true; + } + try { + await resource.update(updateData); + } catch (error) { + } finally { + actionField.data.loading = false; + } + service?.refresh?.(); + if (!(resource instanceof TableFieldResource)) { + __parent?.service?.refresh?.(); + } + if (!onSuccess?.successMessage) { + return; + } + if (onSuccess?.manualClose) { + Modal.success({ + title: compile(onSuccess?.successMessage), + onOk: async () => { + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + history.push(onSuccess.redirectTo); + } + } + }, + }); + } else { + message.success(compile(onSuccess?.successMessage)); + } + }, + async onCancel() { + actionField.data.loading = false; + }, + }); + }, + }; +}; + +export const useCustomizeBulkEditActionProps = () => { + const form = useForm(); + const { t } = useTranslation(); + const { field, resource, __parent } = useBlockRequestContext(); + const actionContext = useActionContext(); + const history = useHistory(); + const compile = useCompile(); + const actionField = useField(); + const tableBlockContext = useTableBlockContext(); + const { rowKey } = tableBlockContext; + const { selectedRowKeys } = tableBlockContext.field?.data ?? {}; + const { setVisible, fieldSchema: actionSchema } = actionContext; + return { + async onClick() { + const { onSuccess, skipValidator, updateMode } = actionSchema?.['x-action-settings'] ?? {}; + const { filter } = __parent.service.params?.[0] ?? {}; + if (!skipValidator) { + await form.submit(); + } + let values = cloneDeep(form.values); + actionField.data = field.data || {}; + actionField.data.loading = true; + for (const key in values) { + if (Object.prototype.hasOwnProperty.call(values, key)) { + const value = values[key]; + if (BulkEditFormItemValueType.Clear in value) { + values[key] = null; + } else if (BulkEditFormItemValueType.ChangedTo in value) { + values[key] = value[BulkEditFormItemValueType.ChangedTo]; + } else if (BulkEditFormItemValueType.RemainsTheSame in value) { + delete values[key]; + } + } + } + try { + const updateData: { filter?: any; values: any; forceUpdate: boolean } = { + values, + filter, + forceUpdate: false, + }; + if (updateMode === 'selected') { + if (!selectedRowKeys?.length) { + message.error(t('Please select the records to be updated')); + return; + } + updateData.filter = { $and: [{ [rowKey || 'id']: { $in: selectedRowKeys } }] }; + } + if (!updateData.filter) { + updateData.forceUpdate = true; + } + await resource.update(updateData); + actionField.data.loading = false; + if (!(resource instanceof TableFieldResource)) { + __parent?.__parent?.service?.refresh?.(); + } + __parent?.service?.refresh?.(); + setVisible?.(false); + if (!onSuccess?.successMessage) { + return; + } + if (onSuccess?.manualClose) { + Modal.success({ + title: compile(onSuccess?.successMessage), + onOk: async () => { + await form.reset(); + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + history.push(onSuccess.redirectTo); + } + } + }, + }); + } else { + message.success(compile(onSuccess?.successMessage)); + } + } finally { + actionField.data.loading = false; + } + }, + }; +}; + export const useCustomizeRequestActionProps = () => { const apiClient = useAPIClient(); const history = useHistory(); diff --git a/packages/core/client/src/collection-manager/interfaces/o2m.tsx b/packages/core/client/src/collection-manager/interfaces/o2m.tsx index fd5f80271..627571810 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2m.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2m.tsx @@ -86,8 +86,8 @@ export const o2m: IField = { }, }; } else { - schema['x-component'] = 'CollectionField'; - schema.type = 'array'; + // schema['x-component'] = 'CollectionField'; + // schema.type = 'array'; if (block === 'Form') { schema['properties'] = { @@ -102,7 +102,7 @@ export const o2m: IField = { } else { schema['properties'] = { selector: cloneDeep(recordPickerSelector), - } + }; } } } diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index f9c545957..286f442aa 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -557,4 +557,16 @@ export default { "Print": "Print", 'Single select and radio fields can be used as the grouping field': 'Single select and radio fields can be used as the grouping field', 'Sign up successfully, and automatically jump to the sign in page': 'Sign up successfully, and automatically jump to the sign in page', + "After successful bulk update": "After successful bulk update", + "All": "All", + "Update selected data?": "Update selected data?", + "Update all data?": "Update all data?", + "Bulk edit": "Bulk edit", + "Data will be updated": "Data will be updated", + "Selected": "Selected", + "Remains the same": "Remains the same", + "Changed to": "Changed to", + "Clear":"Clear", + "Add attach":"Add attach", + "Please select the records to be updated": "Please select the records to be updated" } diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index ab3c0826d..2ff4e2d7d 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -234,6 +234,7 @@ export default { "Edit button": "编辑按钮", "Hide": "隐藏", "Enable actions": "启用操作", + "Import": "导入", "Export": "导出", "Customize": "自定义", "Function": "Function", @@ -326,6 +327,7 @@ export default { "Title": "标题", "Select view": "切换视图", "Reset": "重置", + "Importable fields": "可导入字段", "Exportable fields": "可导出字段", "Saved successfully": "保存成功", "Nickname": "昵称", @@ -691,6 +693,7 @@ export default { "Enabled languages": "启用的语言", "View all plugins": "查看所有插件", "Print": "打印", + "Done": "完成", 'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面', 'File manager': '文件管理器', 'ACL': '访问控制', @@ -706,4 +709,17 @@ export default { 'Create inverse field in the target collection': '在目标数据表里创建反向关系字段', 'Inverse field name': '反向关系字段标识', 'Inverse field display name': '反向关系字段名称', + "Bulk update": "批量更新", + "After successful bulk update": "批量成功更新后", + "Bulk edit": "批量编辑", + "Data will be updated": "更新的数据", + "Selected": "选中", + "All": "所有", + "Update selected data?": "更新选中的数据吗?", + "Update all data?": "更新全部数据吗?", + "Remains the same": "不更新", + "Changed to": "修改为", + "Clear":"清空", + "Add attach":"增加关联", + "Please select the records to be updated": "请选择要更新的记录" } diff --git a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx index dedb80c28..05d715128 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx @@ -40,6 +40,7 @@ export const ActionDesigner = (props) => { const { t } = useTranslation(); const compile = useCompile(); const isPopupAction = ['create', 'update', 'view', 'customize:popup'].includes(fieldSchema['x-action'] || ''); + const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']); const context = useActionContext(); const [initialSchema, setInitialSchema] = useState(); const actionType = fieldSchema['x-action'] || ''; @@ -152,6 +153,26 @@ export const ActionDesigner = (props) => { }} /> )} + {isUpdateModePopupAction && ( + { + fieldSchema['x-action-settings']['updateMode'] = value; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + dn.refresh(); + }} + /> + )} {isValid(fieldSchema?.['x-action-settings']?.assignedValues) && ( { 'customize:update': t('After successful update'), 'customize:table:request': t('After successful request'), 'customize:form:request': t('After successful request'), + 'customize:bulkUpdate': t('After successful bulk update'), }[actionType] } initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']} @@ -258,6 +280,7 @@ export const ActionDesigner = (props) => { 'customize:update': t('After successful update'), 'customize:table:request': t('After successful request'), 'customize:form:request': t('After successful request'), + 'customize:bulkUpdate': t('After successful bulk update'), }[actionType], properties: { successMessage: { diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 48f7483b1..95df54418 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -74,7 +74,6 @@ export const Action: ComposedAction = observer((props: any) => { component, useAction = useA, className, - disabled, icon, title, ...others @@ -130,6 +129,7 @@ export const Action: ComposedAction = observer((props: any) => { setFormValueChanged, openMode, containerRefKey, + fieldSchema, }} > {popover && } diff --git a/packages/core/client/src/schema-component/antd/action/context.tsx b/packages/core/client/src/schema-component/antd/action/context.tsx index 7c0ea0af1..7281a2c22 100644 --- a/packages/core/client/src/schema-component/antd/action/context.tsx +++ b/packages/core/client/src/schema-component/antd/action/context.tsx @@ -1,3 +1,4 @@ +import { Schema } from '@formily/react'; import { createContext } from 'react'; export const ActionContext = createContext({}); @@ -10,4 +11,5 @@ export interface ActionContextProps { containerRefKey?: string; formValueChanged?: boolean; setFormValueChanged?: (v: boolean) => void; + fieldSchema?: Schema; } diff --git a/packages/core/client/src/schema-component/antd/checkbox/Checkbox.tsx b/packages/core/client/src/schema-component/antd/checkbox/Checkbox.tsx index ce7165f25..14bb1cc0c 100644 --- a/packages/core/client/src/schema-component/antd/checkbox/Checkbox.tsx +++ b/packages/core/client/src/schema-component/antd/checkbox/Checkbox.tsx @@ -12,7 +12,12 @@ type ComposedCheckbox = React.FC & { }; export const Checkbox: ComposedCheckbox = connect( - AntdCheckbox, + (props: any) => { + const changeHandler = (val) => { + props?.onChange(val); + }; + return ; + }, mapProps( { value: 'checked', diff --git a/packages/core/client/src/schema-component/antd/filter/Filter.Action.Designer.tsx b/packages/core/client/src/schema-component/antd/filter/Filter.Action.Designer.tsx index c0b01586e..09f6f03f7 100644 --- a/packages/core/client/src/schema-component/antd/filter/Filter.Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/filter/Filter.Action.Designer.tsx @@ -37,6 +37,7 @@ export const FilterActionDesigner = (props) => { const checked = !nonfilterable.includes(field.name); return ( { diff --git a/packages/core/client/src/schema-component/antd/markdown/Markdown.Void.tsx b/packages/core/client/src/schema-component/antd/markdown/Markdown.Void.tsx index 7d9997670..34c4fa20e 100644 --- a/packages/core/client/src/schema-component/antd/markdown/Markdown.Void.tsx +++ b/packages/core/client/src/schema-component/antd/markdown/Markdown.Void.tsx @@ -1,5 +1,6 @@ import { observer, useField, useFieldSchema } from '@formily/react'; import { Button, Input as AntdInput, Space } from 'antd'; +import cls from 'classnames'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDesignable } from '../../hooks/useDesignable'; @@ -41,7 +42,7 @@ const MarkdownEditor = (props: any) => { }; export const MarkdownVoid: any = observer((props: any) => { - const { content } = props; + const { content, className } = props; const field = useField(); const schema = useFieldSchema(); const { dn } = useDesignable(); @@ -49,6 +50,7 @@ export const MarkdownVoid: any = observer((props: any) => { return field?.editable ? ( { field.editable = false; @@ -71,7 +73,7 @@ export const MarkdownVoid: any = observer((props: any) => { }} /> ) : ( -
+
); }); diff --git a/packages/core/client/src/schema-component/antd/upload/Upload.tsx b/packages/core/client/src/schema-component/antd/upload/Upload.tsx index 32bf720ac..1594cf0ea 100644 --- a/packages/core/client/src/schema-component/antd/upload/Upload.tsx +++ b/packages/core/client/src/schema-component/antd/upload/Upload.tsx @@ -195,9 +195,13 @@ Upload.Attachment = connect((props: UploadProps) => { Upload.Dragger = connect( (props: DraggerProps) => { + const { tipContent } = props; return (
- + + {tipContent} + {props.children} +
); }, diff --git a/packages/core/client/src/schema-component/antd/upload/type.d.ts b/packages/core/client/src/schema-component/antd/upload/type.d.ts index 150d0b550..c8023280d 100644 --- a/packages/core/client/src/schema-component/antd/upload/type.d.ts +++ b/packages/core/client/src/schema-component/antd/upload/type.d.ts @@ -12,6 +12,8 @@ export type UploadProps = Omit & { export type DraggerProps = Omit & { onChange?: (fileList: UploadFile[]) => void; serviceErrorMessage?: string; + tipContent?: string | React.ReactNode; + children?: React.ReactNode; }; export type ComposedUpload = React.FC & { diff --git a/packages/core/client/src/schema-component/common/sortable-item/SortableItem.tsx b/packages/core/client/src/schema-component/common/sortable-item/SortableItem.tsx index 7cf38628c..ddaeb462f 100644 --- a/packages/core/client/src/schema-component/common/sortable-item/SortableItem.tsx +++ b/packages/core/client/src/schema-component/common/sortable-item/SortableItem.tsx @@ -27,7 +27,6 @@ export const Sortable = (props: any) => { if (isOver) { droppableStyle['color'] = 'rgba(241, 139, 98, .1)'; } - return React.createElement( component || 'div', { diff --git a/packages/core/client/src/schema-initializer/buttons/BulkEditFormItemInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/BulkEditFormItemInitializers.tsx new file mode 100644 index 000000000..ba4168e6c --- /dev/null +++ b/packages/core/client/src/schema-initializer/buttons/BulkEditFormItemInitializers.tsx @@ -0,0 +1,65 @@ +import { union } from 'lodash'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SchemaInitializer } from '../SchemaInitializer'; +import { + gridRowColWrap, + useAssociatedFormItemInitializerFields, + useCustomBulkEditFormItemInitializerFields, +} from '../utils'; + +export const BulkEditFormItemInitializers = (props: any) => { + const { t } = useTranslation(); + const { insertPosition, component } = props; + const associationFields = useAssociatedFormItemInitializerFields({ readPretty: true, block: 'Form' }); + return ( + ( + [ + { + type: 'itemGroup', + title: t('Display fields'), + children: useCustomBulkEditFormItemInitializerFields(), + }, + ], + associationFields.length > 0 + ? [ + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t('Display association fields'), + children: associationFields, + }, + ] + : [], + [ + { + type: 'divider', + }, + { + type: 'item', + title: t('Add text'), + component: 'BlockInitializer', + schema: { + type: 'void', + 'x-editable': false, + 'x-decorator': 'FormItem', + 'x-designer': 'Markdown.Void.Designer', + 'x-component': 'Markdown.Void', + 'x-component-props': { + content: t('This is a demo text, **supports Markdown syntax**.'), + }, + }, + }, + ], + )} + insertPosition={insertPosition} + component={component} + title={component ? null : t('Configure fields')} + /> + ); +}; diff --git a/packages/core/client/src/schema-initializer/buttons/CreateFormBulkEditBlockInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/CreateFormBulkEditBlockInitializers.tsx new file mode 100644 index 000000000..8a4cce1bf --- /dev/null +++ b/packages/core/client/src/schema-initializer/buttons/CreateFormBulkEditBlockInitializers.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SchemaInitializer } from '../..'; +import { gridRowColWrap } from '../utils'; + +export const CreateFormBulkEditBlockInitializers = (props: any) => { + const { t } = useTranslation(); + const { insertPosition, component } = props; + return ( + + ); +}; diff --git a/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx index 0f3405ed8..0cc57a44f 100644 --- a/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx @@ -388,3 +388,133 @@ export const UpdateFormActionInitializers = { }, ], }; + +export const BulkEditFormActionInitializers = { + title: '{{t("Configure actions")}}', + icon: 'SettingOutlined', + items: [ + { + type: 'itemGroup', + title: '{{t("Enable actions")}}', + children: [ + { + type: 'item', + title: '{{t("Submit")}}', + component: 'BulkEditSubmitActionInitializer', + schema: { + 'x-action-settings': {}, + }, + }, + ], + }, + { + type: 'divider', + }, + { + type: 'subMenu', + title: '{{t("Customize")}}', + children: [ + { + type: 'item', + title: '{{t("Popup")}}', + component: 'CustomizeActionInitializer', + schema: { + type: 'void', + title: '{{ t("Popup") }}', + 'x-action': 'customize:popup', + 'x-designer': 'Action.Designer', + 'x-component': 'Action', + 'x-component-props': { + openMode: 'drawer', + }, + properties: { + drawer: { + type: 'void', + title: '{{ t("Popup") }}', + 'x-component': 'Action.Container', + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + tabs: { + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializers', + properties: { + tab1: { + type: 'void', + title: '{{t("Details")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'RecordBlockInitializers', + properties: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + type: 'item', + title: '{{t("Save record")}}', + component: 'CustomizeActionInitializer', + schema: { + title: '{{ t("Save") }}', + 'x-component': 'Action', + 'x-action': 'customize:save', + 'x-designer': 'Action.Designer', + 'x-designer-props': { + modalTip: + '{{ t("When the button is clicked, the following fields will be assigned and saved together with the fields in the form. If there are overlapping fields, the value here will overwrite the value in the form.") }}', + }, + 'x-action-settings': { + assignedValues: {}, + skipValidator: false, + onSuccess: { + manualClose: true, + redirecting: false, + successMessage: '{{t("Saved successfully")}}', + }, + }, + 'x-component-props': { + useProps: '{{ useUpdateActionProps }}', + }, + }, + }, + { + type: 'item', + title: '{{t("Custom request")}}', + component: 'CustomizeActionInitializer', + schema: { + title: '{{ t("Custom request") }}', + 'x-component': 'Action', + 'x-action': 'customize:form:request', + 'x-designer': 'Action.Designer', + 'x-action-settings': { + requestSettings: {}, + skipValidator: false, + onSuccess: { + manualClose: false, + redirecting: false, + successMessage: '{{t("Request success")}}', + }, + }, + 'x-component-props': { + useProps: '{{ useCustomizeRequestActionProps }}', + }, + }, + }, + ], + }, + ], +}; diff --git a/packages/core/client/src/schema-initializer/buttons/TabPaneInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TabPaneInitializers.tsx index 5e99536ae..e4c6f8194 100644 --- a/packages/core/client/src/schema-initializer/buttons/TabPaneInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TabPaneInitializers.tsx @@ -11,6 +11,12 @@ export const TabPaneInitializers = (props?: any) => { const form = useForm(); const ctx = useActionContext(); const index = useRecordIndex(); + let initializer = 'RecordBlockInitializers'; + if (props.isCreate || index === null) { + initializer = 'CreateFormBlockInitializers'; + } else if (props.isBulkEdit) { + initializer = 'CreateFormBulkEditBlockInitializers'; + } return { async run() { await form.submit(); @@ -117,3 +123,7 @@ export const TabPaneInitializers = (props?: any) => { export const TabPaneInitializersForCreateFormBlock = () => { return ; }; + +export const TabPaneInitializersForBulkEditFormBlock = () => { + return ; +}; diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx index 2fe60633a..8dcdb4e02 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx @@ -56,5 +56,55 @@ export const TableActionInitializers = { }, ], }, + { + type: 'divider', + }, + { + type: 'subMenu', + title: '{{t("Customize")}}', + children: [ + { + type: 'item', + title: '{{t("Bulk update")}}', + component: 'CustomizeActionInitializer', + schema: { + type: 'void', + title: '{{ t("Bulk update") }}', + 'x-component': 'Action', + 'x-align': 'right', + 'x-decorator': 'ACLActionProvider', + 'x-acl-action-props': { + skipScopeCheck: true, + }, + 'x-action': 'customize:bulkUpdate', + 'x-designer': 'Action.Designer', + 'x-action-settings': { + assignedValues: {}, + updateMode: 'selected', + onSuccess: { + manualClose: true, + redirecting: false, + successMessage: '{{t("Updated successfully")}}', + }, + }, + 'x-component-props': { + useProps: '{{ useCustomizeBulkUpdateActionProps }}', + }, + }, + }, + { + type: 'item', + title: '{{t("Bulk edit")}}', + component: 'CustomizeBulkEditActionInitializer', + schema: { + 'x-align': 'right', + 'x-decorator': 'ACLActionProvider', + 'x-acl-action-props': { + skipScopeCheck: true, + }, + }, + }, + ], + }, ], }; diff --git a/packages/core/client/src/schema-initializer/buttons/index.ts b/packages/core/client/src/schema-initializer/buttons/index.ts index 4b4926020..75b1e03ed 100644 --- a/packages/core/client/src/schema-initializer/buttons/index.ts +++ b/packages/core/client/src/schema-initializer/buttons/index.ts @@ -1,6 +1,8 @@ export * from './BlockInitializers'; +export * from './BulkEditFormItemInitializers'; export * from './CalendarActionInitializers'; export * from './CreateFormBlockInitializers'; +export * from './CreateFormBulkEditBlockInitializers'; export * from './CustomFormItemInitializers'; export * from './DetailsActionInitializers'; export * from './FormActionInitializers'; diff --git a/packages/core/client/src/schema-initializer/components/BulkEditField.tsx b/packages/core/client/src/schema-initializer/components/BulkEditField.tsx new file mode 100644 index 000000000..d7567b12f --- /dev/null +++ b/packages/core/client/src/schema-initializer/components/BulkEditField.tsx @@ -0,0 +1,143 @@ +import { Field } from '@formily/core'; +import { connect, useField, useFieldSchema } from '@formily/react'; +import { merge, uid } from '@formily/shared'; +import { Checkbox, Select, Space } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFormBlockContext } from '../../block-provider'; +import { CollectionFieldProvider, useCollection, useCollectionField } from '../../collection-manager'; +import { useCompile, useComponent } from '../../schema-component'; + +const InternalField: React.FC = (props) => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { name, interface: interfaceType, uiSchema } = useCollectionField(); + const component = useComponent(uiSchema?.['x-component']); + const compile = useCompile(); + const setFieldProps = (key, value) => { + field[key] = typeof field[key] === 'undefined' ? value : field[key]; + }; + const setRequired = () => { + if (typeof fieldSchema['required'] === 'undefined') { + field.required = !!uiSchema['required']; + } + }; + const ctx = useFormBlockContext(); + + useEffect(() => { + if (ctx?.field) { + ctx.field.added = ctx.field.added || new Set(); + ctx.field.added.add(fieldSchema.name); + } + }); + + useEffect(() => { + if (!uiSchema) { + return; + } + setFieldProps('content', uiSchema['x-content']); + setFieldProps('description', uiSchema.description); + setFieldProps('initialValue', uiSchema.default); + // if (!field.validator && uiSchema['x-validator']) { + // field.validator = uiSchema['x-validator']; + // } + if (fieldSchema['x-disabled'] === true) { + field.disabled = true; + } + if (fieldSchema['x-read-pretty'] === true) { + field.readPretty = true; + } + setRequired(); + // @ts-ignore + field.dataSource = uiSchema.enum; + const originalProps = compile(uiSchema['x-component-props']) || {}; + const componentProps = merge(originalProps, field.componentProps || {}); + field.componentProps = componentProps; + // field.component = [component, componentProps]; + }, [JSON.stringify(uiSchema)]); + if (!uiSchema) { + return null; + } + return React.createElement(component, props, props.children); +}; + +const CollectionField = connect((props) => { + const fieldSchema = useFieldSchema(); + return ( + + + + ); +}); + +export enum BulkEditFormItemValueType { + RemainsTheSame = 1, + ChangedTo, + Clear, + AddAttach, +} + +export const BulkEditField = (props: any) => { + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const field = useField(); + const [type, setType] = useState(BulkEditFormItemValueType.RemainsTheSame); + const [value, setValue] = useState(null); + const { getField } = useCollection(); + const collectionField = getField(fieldSchema.name); + + useEffect(() => { + field.value = { [type]: value }; + }, [type, value]); + + const typeChangeHandler = (val) => { + setType(val); + }; + + const valueChangeHandler = (val) => { + setValue(val?.target?.value ?? val?.target?.checked ?? val); + }; + + const collectionSchema: any = { + type: 'void', + properties: { + [uid()]: { + type: 'string', + 'x-component': 'BulkEditCollectionField', + 'x-collection-field': fieldSchema['x-collection-field'], + 'x-component-props': { + ...props, + value, + onChange: valueChangeHandler, + style: { minWidth: 150 }, + }, + 'x-decorator': 'FormItem', + }, + }, + }; + + return ( + + + {/* XXX: Not a best practice */} + {[BulkEditFormItemValueType.ChangedTo, BulkEditFormItemValueType.AddAttach].includes(type) && + collectionField.interface !== 'checkbox' && ( + + // + )} + {[BulkEditFormItemValueType.ChangedTo, BulkEditFormItemValueType.AddAttach].includes(type) && + collectionField.interface === 'checkbox' && } + + ); +}; diff --git a/packages/core/client/src/schema-initializer/components/index.ts b/packages/core/client/src/schema-initializer/components/index.ts index f7d836316..14a252069 100644 --- a/packages/core/client/src/schema-initializer/components/index.ts +++ b/packages/core/client/src/schema-initializer/components/index.ts @@ -1 +1,2 @@ export * from './assigned-field'; +export * from './BulkEditField'; diff --git a/packages/core/client/src/schema-initializer/items/BlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/BlockInitializer.tsx index 8e7d6b772..729fd2feb 100644 --- a/packages/core/client/src/schema-initializer/items/BlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/BlockInitializer.tsx @@ -1,16 +1,16 @@ +import { merge } from '@formily/shared'; import React from 'react'; - -import { SchemaInitializer } from ".."; +import { SchemaInitializer } from '..'; // Block export const BlockInitializer = (props) => { - const { item, insert } = props; + const { item, schema, insert } = props; return ( { - insert({ - ...item.schema, - }); + const s = merge(schema || {}, item.schema || {}); + item?.schemaInitialize?.(s); + insert(s); }} /> ); diff --git a/packages/core/client/src/schema-initializer/items/BulkEditSubmitActionInitializer.tsx b/packages/core/client/src/schema-initializer/items/BulkEditSubmitActionInitializer.tsx new file mode 100644 index 000000000..1dd7c7b61 --- /dev/null +++ b/packages/core/client/src/schema-initializer/items/BulkEditSubmitActionInitializer.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { ActionInitializer } from './ActionInitializer'; + +export const BulkEditSubmitActionInitializer = (props) => { + const schema = { + title: '{{ t("Submit") }}', + 'x-action': 'submit', + 'x-component': 'Action', + 'x-designer': 'Action.Designer', + 'x-component-props': { + type: 'primary', + htmlType: 'submit', + useProps: '{{ useCustomizeBulkEditActionProps }}', + }, + }; + return ; +}; diff --git a/packages/core/client/src/schema-initializer/items/CreateFormBulkEditBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/CreateFormBulkEditBlockInitializer.tsx new file mode 100644 index 000000000..a78376735 --- /dev/null +++ b/packages/core/client/src/schema-initializer/items/CreateFormBulkEditBlockInitializer.tsx @@ -0,0 +1,49 @@ +import { FormOutlined } from '@ant-design/icons'; +import React from 'react'; +import { useBlockAssociationContext } from '../../block-provider'; +import { useCollection } from '../../collection-manager'; +import { useSchemaTemplateManager } from '../../schema-templates'; +import { SchemaInitializer } from '../SchemaInitializer'; +import { createFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils'; + +export const CreateFormBulkEditBlockInitializer = (props) => { + const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props; + const { getTemplateSchemaByMode } = useSchemaTemplateManager(); + const association = useBlockAssociationContext(); + const collection = useCollection(); + return ( + } + {...others} + onClick={async ({ item }) => { + if (item.template) { + const s = await getTemplateSchemaByMode(item); + if (item.template.componentName === 'FormItem') { + const blockSchema = createFormBlockSchema({ + actionInitializers: 'CreateFormActionInitializers', + association, + collection: collection.name, + template: s, + }); + if (item.mode === 'reference') { + blockSchema['x-template-key'] = item.template.key; + } + insert(blockSchema); + } else { + insert(s); + } + } else { + insert( + createFormBlockSchema({ + formItemInitializers: 'BulkEditFormItemInitializers', + actionInitializers: 'BulkEditFormActionInitializers', + association, + collection: collection.name, + }), + ); + } + }} + items={useRecordCollectionDataSourceItems('FormItem')} + /> + ); +}; diff --git a/packages/core/client/src/schema-initializer/items/CustomizeBulkEditActionInitializer.tsx b/packages/core/client/src/schema-initializer/items/CustomizeBulkEditActionInitializer.tsx new file mode 100644 index 000000000..918a05b5e --- /dev/null +++ b/packages/core/client/src/schema-initializer/items/CustomizeBulkEditActionInitializer.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { BlockInitializer } from './BlockInitializer'; + +export const CustomizeBulkEditActionInitializer = (props) => { + const schema = { + type: 'void', + title: '{{t("Bulk edit")}}', + 'x-designer': 'Action.Designer', + 'x-component': 'Action', + 'x-action': 'customize:bulkEdit', + 'x-action-settings': { + updateMode: 'selected', + }, + 'x-component-props': { + openMode: 'drawer', + }, + properties: { + drawer: { + type: 'void', + title: '{{t("Bulk edit")}}', + 'x-component': 'Action.Container', + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + tabs: { + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializersForBulkEditFormBlock', + properties: { + tab1: { + type: 'void', + title: '{{t("Bulk edit")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'CreateFormBulkEditBlockInitializers', + properties: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }; + return ; +}; diff --git a/packages/core/client/src/schema-initializer/items/index.tsx b/packages/core/client/src/schema-initializer/items/index.tsx index 4c6ec6b3e..ab7613381 100644 --- a/packages/core/client/src/schema-initializer/items/index.tsx +++ b/packages/core/client/src/schema-initializer/items/index.tsx @@ -1,12 +1,15 @@ export * from './ActionInitializer'; export * from './BlockInitializer'; export * from './BulkDestroyActionInitializer'; +export * from './BulkEditSubmitActionInitializer'; export * from './CalendarBlockInitializer'; export * from './CollectionFieldInitializer'; export * from './CreateActionInitializer'; export * from './CreateFormBlockInitializer'; +export * from './CreateFormBulkEditBlockInitializer'; export * from './CreateSubmitActionInitializer'; export * from './CustomizeActionInitializer'; +export * from './CustomizeBulkEditActionInitializer'; export * from './DataBlockInitializer'; export * from './DestroyActionInitializer'; export * from './DetailsBlockInitializer'; diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 2eda1c82e..3a35091f5 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -82,8 +82,7 @@ export const useTableColumnInitializerFields = () => { 'x-collection-field': `${name}.${field.name}`, 'x-component': 'CollectionField', 'x-read-pretty': true, - 'x-component-props': { - }, + 'x-component-props': {}, }; // interfaceConfig?.schemaInitialize?.(schema, { field, readPretty: true, block: 'Table' }); return { @@ -124,8 +123,7 @@ export const useAssociatedTableColumnInitializerFields = () => { 'x-component': 'CollectionField', 'x-read-pretty': true, 'x-collection-field': `${name}.${field.name}.${subField.name}`, - 'x-component-props': { - }, + 'x-component-props': {}, }; return { @@ -150,7 +148,7 @@ export const useAssociatedTableColumnInitializerFields = () => { }); return groups; -} +}; export const useFormItemInitializerFields = (options?: any) => { const { name, fields } = useCollection(); @@ -171,9 +169,8 @@ export const useFormItemInitializerFields = (options?: any) => { 'x-component': field.interface === 'o2m' ? 'TableField' : 'CollectionField', 'x-decorator': 'FormItem', 'x-collection-field': `${name}.${field.name}`, + 'x-component-props': {}, 'x-read-pretty': field?.uiSchema?.['x-read-pretty'], - 'x-component-props': { - }, }; // interfaceConfig?.schemaInitialize?.(schema, { field, block: 'Form', readPretty: form.readPretty }); return { @@ -194,7 +191,7 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => { const { getInterface, getCollectionFields } = useCollectionManager(); const form = useForm(); const { readPretty = form.readPretty, block = 'Form' } = options || {}; - const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o'] + const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o']; const groups = fields ?.filter((field) => { @@ -239,7 +236,7 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => { } as SchemaInitializerItemOptions; }); return groups; -} +}; export const useCustomFormItemInitializerFields = (options?: any) => { const { name, fields } = useCollection(); @@ -275,6 +272,40 @@ export const useCustomFormItemInitializerFields = (options?: any) => { }); }; +export const useCustomBulkEditFormItemInitializerFields = (options?: any) => { + const { name, fields } = useCollection(); + const { getInterface } = useCollectionManager(); + const form = useForm(); + const { readPretty = form.readPretty, block = 'Form' } = options || {}; + const remove = useRemoveGridFormItem(); + return fields + ?.filter((field) => { + return field?.interface && !field?.uiSchema?.['x-read-pretty']; + }) + ?.map((field) => { + const interfaceConfig = getInterface(field.interface); + const schema = { + type: 'string', + name: field.name, + title: field?.uiSchema?.title || field.name, + 'x-designer': 'FormItem.Designer', + 'x-component': 'BulkEditField', + 'x-decorator': 'FormItem', + 'x-collection-field': `${name}.${field.name}`, + }; + return { + type: 'item', + title: field?.uiSchema?.title || field.name, + component: 'CollectionFieldInitializer', + remove: remove, + schemaInitialize: (s) => { + interfaceConfig?.schemaInitialize?.(s, { field, block, readPretty }); + }, + schema, + } as SchemaInitializerItemOptions; + }); +}; + const findSchema = (schema: Schema, key: string, action: string) => { if (!Schema.isSchemaInstance(schema)) return null; return schema.reduceProperties((buf, s) => { @@ -299,7 +330,7 @@ const recursiveParent = (schema: Schema) => { if (schema.parent['x-initializer']) return schema.parent; return recursiveParent(schema.parent); -} +}; export const useCurrentSchema = (action: string, key: string, find = findSchema, rm = removeSchema) => { let fieldSchema = useFieldSchema(); @@ -320,17 +351,22 @@ export const useCurrentSchema = (action: string, key: string, find = findSchema, }; }; -export const useRecordCollectionDataSourceItems = (componentName, item = null, collectionName = null, resourceName = null) => { +export const useRecordCollectionDataSourceItems = ( + componentName, + item = null, + collectionName = null, + resourceName = null, +) => { const { t } = useTranslation(); const collection = useCollection(); const { getTemplatesByCollection } = useSchemaTemplateManager(); const templates = getTemplatesByCollection(collectionName || collection.name) - .filter((template) => { - return componentName && template.componentName === componentName; - }) - .filter((template) => { - return ['FormItem', 'ReadPrettyFormItem'].includes(componentName) || (template.resourceName === resourceName); - }); + .filter((template) => { + return componentName && template.componentName === componentName; + }) + .filter((template) => { + return ['FormItem', 'ReadPrettyFormItem'].includes(componentName) || template.resourceName === resourceName; + }); if (!templates.length) { return []; } @@ -352,8 +388,9 @@ export const useRecordCollectionDataSourceItems = (componentName, item = null, c name: 'copy', title: t('Duplicate template'), children: templates.map((template) => { - const templateName = - ['FormItem', 'ReadPrettyFormItem'].includes(template?.componentName) ? `${template?.name} ${t('(Fields only)')}` : template?.name; + const templateName = ['FormItem', 'ReadPrettyFormItem'].includes(template?.componentName) + ? `${template?.name} ${t('(Fields only)')}` + : template?.name; return { type: 'item', mode: 'copy', @@ -370,8 +407,9 @@ export const useRecordCollectionDataSourceItems = (componentName, item = null, c name: 'ref', title: t('Reference template'), children: templates.map((template) => { - const templateName = - ['FormItem', 'ReadPrettyFormItem'].includes(template?.componentName) ? `${template?.name} ${t('(Fields only)')}` : template?.name; + const templateName = ['FormItem', 'ReadPrettyFormItem'].includes(template?.componentName) + ? `${template?.name} ${t('(Fields only)')}` + : template?.name; return { type: 'item', mode: 'reference', @@ -398,7 +436,11 @@ export const useCollectionDataSourceItems = (componentName) => { ?.filter((item) => !item.inherit) ?.map((item, index) => { const templates = getTemplatesByCollection(item.name).filter((template) => { - return componentName && template.componentName === componentName && (!template.resourceName || template.resourceName === item.name); + return ( + componentName && + template.componentName === componentName && + (!template.resourceName || template.resourceName === item.name) + ); }); if (!templates.length) { return { diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index d1b40a128..a830a19bf 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { FormDialog, FormItem, FormLayout, Input } from '@formily/antd'; import { createForm, Field, GeneralField } from '@formily/core'; -import { ISchema, Schema, SchemaOptionsContext, useField, useFieldSchema } from '@formily/react'; +import { ISchema, Schema, SchemaOptionsContext, useField, useFieldSchema, useForm } from '@formily/react'; import { uid } from '@formily/shared'; import { Alert, Button, Dropdown, Menu, MenuItemProps, Modal, Select, Space, Switch } from 'antd'; import classNames from 'classnames'; @@ -22,7 +22,7 @@ import { useAPIClient, useCollection, useCompile, - useDesignable + useDesignable, } from '..'; import { useSchemaTemplateManager } from '../schema-templates'; import { useBlockTemplateContext } from '../schema-templates/BlockTemplate'; @@ -382,6 +382,7 @@ SchemaSettings.Remove = (props: any) => { const field = useField(); const fieldSchema = useFieldSchema(); const ctx = useBlockTemplateContext(); + const form = useForm(); return ( { @@ -403,6 +404,7 @@ SchemaSettings.Remove = (props: any) => { } else { dn.remove(null, options); } + delete form.values[fieldSchema.name]; }, }); }} diff --git a/packages/plugins/export/src/client/useExportAction.ts b/packages/plugins/export/src/client/useExportAction.ts index 8ff927597..b8f836eae 100644 --- a/packages/plugins/export/src/client/useExportAction.ts +++ b/packages/plugins/export/src/client/useExportAction.ts @@ -6,6 +6,7 @@ import { useCollectionManager, useCompile, } from '@nocobase/client'; +import { saveAs } from 'file-saver'; import { cloneDeep } from 'lodash'; import { useTranslation } from 'react-i18next'; @@ -49,14 +50,7 @@ export const useExportAction = () => { }, ); let blob = new Blob([data], { type: 'application/x-xls' }); - const a = document.createElement('a'); - const blobUrl = window.URL.createObjectURL(blob); - a.download = `${compile(title)}.xlsx`; - a.href = blobUrl; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(blobUrl); - document.body.removeChild(a); + saveAs(blob, `${compile(title)}.xlsx`); }, }; }; diff --git a/packages/plugins/import/client.d.ts b/packages/plugins/import/client.d.ts new file mode 100644 index 000000000..765db9222 --- /dev/null +++ b/packages/plugins/import/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/plugins/import/client.js b/packages/plugins/import/client.js new file mode 100644 index 000000000..238820257 --- /dev/null +++ b/packages/plugins/import/client.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/client")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/import/package.json b/packages/plugins/import/package.json new file mode 100644 index 000000000..b5c002502 --- /dev/null +++ b/packages/plugins/import/package.json @@ -0,0 +1,14 @@ +{ + "name": "@nocobase/plugin-import", + "version": "0.8.0-alpha.1", + "main": "lib/server/index.js", + "dependencies": {}, + "devDependencies": { + "@nocobase/client": "0.8.0-alpha.1", + "@nocobase/test": "0.8.0-alpha.1" + }, + "peerDependencies": { + "@nocobase/server": "*", + "@nocobase/test": "*" + } +} diff --git a/packages/plugins/import/server.d.ts b/packages/plugins/import/server.d.ts new file mode 100644 index 000000000..e70edb928 --- /dev/null +++ b/packages/plugins/import/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/plugins/import/server.js b/packages/plugins/import/server.js new file mode 100644 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/plugins/import/server.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/server")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/import/src/client/ImportActionInitializer.tsx b/packages/plugins/import/src/client/ImportActionInitializer.tsx new file mode 100644 index 000000000..0653776f6 --- /dev/null +++ b/packages/plugins/import/src/client/ImportActionInitializer.tsx @@ -0,0 +1,187 @@ +import { css } from '@emotion/css'; +import type { ISchema } from '@formily/react'; +import { Schema, useFieldSchema } from '@formily/react'; +import { merge } from '@formily/shared'; +import { SchemaInitializer, useCollection, useDesignable } from '@nocobase/client'; +import React from 'react'; +import { NAMESPACE } from './constants'; +import { useFields } from './useFields'; + +const findSchema = (schema: Schema, key: string, action: string) => { + return schema.reduceProperties((buf, s) => { + if (s[key] === action) { + return s; + } + const c = findSchema(s, key, action); + if (c) { + return c; + } + return buf; + }); +}; +const removeSchema = (schema, cb) => { + return cb(schema); +}; +export const useCurrentSchema = (action: string, key: string, find = findSchema, rm = removeSchema) => { + const fieldSchema = useFieldSchema(); + const { remove } = useDesignable(); + const schema = find(fieldSchema, key, action); + return { + schema, + exists: !!schema, + remove() { + schema && rm(schema, remove); + }, + }; +}; + +const initImportSettings = (fields) => { + const importColumns = fields?.filter((f) => !f.children).map((f) => ({ dataIndex: [f.name] })); + return { importColumns, explain: '' }; +}; + +export const ImportActionInitializer = (props) => { + const { item, insert } = props; + const { exists, remove } = useCurrentSchema('import', 'x-action', item.find, item.remove); + const { name } = useCollection(); + const fields = useFields(name); + + const schema: ISchema = { + type: 'void', + title: '{{ t("Import") }}', + 'x-action': 'import', + 'x-action-settings': { + importSettings: { importColumns: [], explain: '' }, + }, + 'x-designer': 'ImportDesigner', + 'x-component': 'Action', + 'x-component-props': { + icon: 'CloudUploadOutlined', + openMode: 'modal', + }, + properties: { + modal: { + type: 'void', + title: `{{ t("Import Data", {ns: "${NAMESPACE}" }) }}`, + 'x-component': 'Action.Container', + 'x-decorator': 'Form', + 'x-component-props': { + width: '50%', + className: css` + .ant-formily-item-label { + height: 30px; + } + `, + }, + properties: { + formLayout: { + type: 'void', + 'x-component': 'FormLayout', + properties: { + download: { + type: 'void', + title: `{{ t("Step 1: Download template", {ns: "${NAMESPACE}" }) }}`, + 'x-component': 'FormItem', + properties: { + tip: { + type: 'void', + 'x-component': 'Markdown.Void', + 'x-editable': false, + 'x-component-props': { + className: css` + padding: 8px 15px; + background-color: #e6f7ff; + border: 1px solid #91d5ff; + margin-bottom: 10px; + li { + line-height: 26px; + } + `, + content: `{{ t("Download tip", {ns: "${NAMESPACE}" }) }}`, + }, + }, + downloadAction: { + type: 'void', + title: `{{ t("Download template", {ns: "${NAMESPACE}" }) }}`, + 'x-component': 'Action', + 'x-component-props': { + className: css` + margin-top: 5px; + `, + useAction: '{{ useDownloadXlsxTemplateAction }}', + }, + }, + }, + }, + upload: { + type: 'array', + title: `{{ t("Step 2: Upload Excel", {ns: "${NAMESPACE}" }) }}`, + 'x-decorator': 'FormItem', + 'x-component': 'Upload.Dragger', + 'x-validator': '{{ uploadValidator }}', + 'x-component-props': { + action: '', + height: '150px', + tipContent: `{{ t("Upload placeholder", {ns: "${NAMESPACE}" }) }}`, + beforeUpload: '{{ beforeUploadHandler }}', + }, + }, + }, + }, + footer: { + 'x-component': 'Action.Container.Footer', + 'x-component-props': {}, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': {}, + properties: { + cancel: { + type: 'void', + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + startImport: { + type: 'void', + title: `{{ t("Start import", {ns: "${NAMESPACE}" }) }}`, + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + htmlType: 'submit', + useAction: '{{ useImportStartAction }}', + }, + 'x-reactions': { + dependencies: ['upload'], + fulfill: { + run: 'validateUpload($form, $self, $deps)', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + return ( + { + if (exists) { + return remove(); + } + schema['x-action-settings']['importSettings'] = initImportSettings(fields); + const s = merge(schema || {}, item.schema || {}); + item?.schemaInitialize?.(s); + insert(s); + }} + /> + ); +}; diff --git a/packages/plugins/import/src/client/ImportDesigner.tsx b/packages/plugins/import/src/client/ImportDesigner.tsx new file mode 100644 index 000000000..99d0ca635 --- /dev/null +++ b/packages/plugins/import/src/client/ImportDesigner.tsx @@ -0,0 +1,123 @@ +import { ArrayItems } from '@formily/antd'; +import type { ISchema } from '@formily/react'; +import { useField, useFieldSchema } from '@formily/react'; +import { GeneralSchemaDesigner, SchemaSettings, useDesignable } from '@nocobase/client'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useShared } from './useShared'; + +export const ImportDesigner = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + const [schema, setSchema] = useState(); + const { importSettingsSchema } = useShared(); + + useEffect(() => { + setSchema(importSettingsSchema); + }, [field.address, fieldSchema?.['x-action-settings']?.['importSettings']]); + + return ( + + { + if (title) { + fieldSchema.title = title; + field.title = title; + field.componentProps.icon = icon; + field.componentProps.danger = type === 'danger'; + field.componentProps.type = type; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].icon = icon; + fieldSchema['x-component-props'].danger = type === 'danger'; + fieldSchema['x-component-props'].type = type; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + title, + 'x-component-props': { + ...fieldSchema['x-component-props'], + }, + }, + }); + dn.refresh(); + } + }} + /> + { + const columns = importColumns + ?.filter((fieldItem) => fieldItem?.dataIndex?.length) + .map((item) => ({ + dataIndex: item.dataIndex.map((di) => di.name ?? di), + title: item.title, + })); + fieldSchema['x-action-settings']['importSettings'] = { importColumns: columns, explain }; + + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + dn.refresh(); + }} + /> + + { + return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar'); + }} + confirm={{ + title: t('Delete action'), + }} + /> + + ); +}; diff --git a/packages/plugins/import/src/client/ImportInitializerProvider.tsx b/packages/plugins/import/src/client/ImportInitializerProvider.tsx new file mode 100644 index 000000000..f9990db99 --- /dev/null +++ b/packages/plugins/import/src/client/ImportInitializerProvider.tsx @@ -0,0 +1,23 @@ +import { SchemaInitializerContext } from '@nocobase/client'; +import { useContext } from 'react'; + +export const ImportInitializerProvider = (props: any) => { + const initializes = useContext(SchemaInitializerContext); + const hasImportAction = initializes.TableActionInitializers.items[0].children.some( + (initialize) => initialize.component === 'ImportActionInitializer', + ); + !hasImportAction && + initializes.TableActionInitializers.items[0].children.push({ + type: 'item', + title: "{{t('Import')}}", + component: 'ImportActionInitializer', + schema: { + 'x-align': 'right', + 'x-decorator': 'ACLActionProvider', + 'x-acl-action-props': { + skipScopeCheck: true, + }, + }, + }); + return props.children; +}; diff --git a/packages/plugins/import/src/client/ImportModal.tsx b/packages/plugins/import/src/client/ImportModal.tsx new file mode 100644 index 000000000..a15d724d8 --- /dev/null +++ b/packages/plugins/import/src/client/ImportModal.tsx @@ -0,0 +1,69 @@ +import { ExclamationCircleFilled, LoadingOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { Button, Modal, Space, Spin } from 'antd'; +import { saveAs } from 'file-saver'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACE } from './constants'; +import { useImportContext } from './context'; + +export const ImportStatus = { + IMPORTING: 1, + IMPORTED: 2, +}; + +export const ImportModal = (props: any) => { + const { t } = useTranslation(NAMESPACE); + const { importModalVisible, importStatus, importResult, setImportModalVisible } = useImportContext(); + const { data: fileData, meta } = importResult ?? {}; + const doneHandler = () => { + setImportModalVisible(false); + }; + const downloadFailureDataHandler = () => { + const arrayBuffer = new Int8Array(fileData?.data); + let blob = new Blob([arrayBuffer], { type: 'application/x-xls' }); + saveAs(blob, `fail.xlsx`); + }; + return ( + +
+ {importStatus === ImportStatus.IMPORTING && ( + } tip={t('Excel data importing')} /> + )} + {importStatus === ImportStatus.IMPORTED && ( + + +

+ {t('Import done, total success have {{successCount}} , total failure have {{failureCount}}', { + ...(meta ?? {}), + })} +

+ + {meta?.failureCount > 0 && ( + + )} + + +
+ )} +
+
+ ); +}; diff --git a/packages/plugins/import/src/client/ImportPluginProvider.tsx b/packages/plugins/import/src/client/ImportPluginProvider.tsx new file mode 100644 index 000000000..b86836c07 --- /dev/null +++ b/packages/plugins/import/src/client/ImportPluginProvider.tsx @@ -0,0 +1,52 @@ +import { SchemaComponentOptions } from '@nocobase/client'; +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ImportActionInitializer, ImportDesigner, ImportInitializerProvider } from '.'; +import { ImportContext } from './context'; +import { ImportModal, ImportStatus } from './ImportModal'; +import { useDownloadXlsxTemplateAction, useImportStartAction } from './useImportAction'; +import { useShared } from './useShared'; + +export const ImportPluginProvider = (props: any) => { + const { uploadValidator, beforeUploadHandler, validateUpload } = useShared(); + return ( + + + {props.children} + + + ); +}; + +export const ImportContextProvider = (props: any) => { + const [importModalVisible, setImportModalVisible] = useState(false); + const [importStatus, setImportStatus] = useState(ImportStatus.IMPORTING); + const [importResult, setImportResult] = useState<{ + data: { type: string; data: any[] }; + meta: { successCount: number; failureCount: number }; + }>(null); + return ( + + {createPortal(, document.body)} + {props.children} + + ); +}; diff --git a/packages/plugins/import/src/client/constants.ts b/packages/plugins/import/src/client/constants.ts new file mode 100644 index 000000000..485f2ad13 --- /dev/null +++ b/packages/plugins/import/src/client/constants.ts @@ -0,0 +1 @@ +export const NAMESPACE = 'import'; diff --git a/packages/plugins/import/src/client/context.ts b/packages/plugins/import/src/client/context.ts new file mode 100644 index 000000000..3e41718c0 --- /dev/null +++ b/packages/plugins/import/src/client/context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export interface ImportContextType { + importModalVisible: boolean; + setImportModalVisible: (visible: boolean) => void; + importStatus: number; + setImportStatus: (status: number) => void; + importResult: { data: { type: string; data: any[] }; meta: { successCount: number; failureCount: number } }; + setImportResult: (result: { + data: { type: string; data: any[] }; + meta: { successCount: number; failureCount: number }; + }) => void; +} + +export const ImportContext = createContext(null); + +export const useImportContext = () => { + return useContext(ImportContext); +}; diff --git a/packages/plugins/import/src/client/index.ts b/packages/plugins/import/src/client/index.ts new file mode 100644 index 000000000..f08ea893d --- /dev/null +++ b/packages/plugins/import/src/client/index.ts @@ -0,0 +1,13 @@ +import { i18n } from '@nocobase/client'; +import { NAMESPACE } from './constants'; +import { enUS, zhCN } from './locale'; + +i18n.addResources('zh-CN', NAMESPACE, zhCN); +i18n.addResources('en-US', NAMESPACE, enUS); + +export * from './ImportActionInitializer'; +export * from './ImportDesigner'; +export * from './ImportInitializerProvider'; +export * from './ImportPluginProvider'; +export { ImportPluginProvider as default } from './ImportPluginProvider'; +export * from './useImportAction'; diff --git a/packages/plugins/import/src/client/locale/en-US.ts b/packages/plugins/import/src/client/locale/en-US.ts new file mode 100644 index 000000000..570c3876f --- /dev/null +++ b/packages/plugins/import/src/client/locale/en-US.ts @@ -0,0 +1,23 @@ +export default { + 'Only one file is allowed to be uploaded': 'Only one file is allowed to be uploaded', + 'File size cannot exceed 10M': 'File size cannot exceed 10M', + 'Please upload the file of Excel': 'Please upload the file of Excel', + 'Import Data': 'Import Data', + 'Start import': 'Start import', + 'Import explain': 'Guide', + 'Download template': 'Download template', + 'Step 1: Download template': 'Step 1: Download template', + 'Step 2: Upload Excel': 'Step 2: Upload Excel', + 'Download tip': + '- Download the template and fill in the data according to the format \r\n - Import only the first worksheet \r\n - Support single import of up to 10,000 rows of data \r\n - Do not change the header of the template to prevent import failure', + 'Upload placeholder': 'Drag and drop the file here or click to upload, file size should not exceed 10M', + 'Excel data importing': 'Excel data importing', + 'Import done, total success have {{successCount}} , total failure have {{failureCount}}': + 'Import is complete, with a total of {{successCount}} successful and {{failureCount}} failed', + 'To download the failure data': 'To download the failure data', + 'Add importable field': 'Add importable field', + Done: 'Done', + Yes: 'Yes', + No: 'No', + 'Field {{fieldName}} does not exist': 'Field {{fieldName}} does not exist', +}; diff --git a/packages/plugins/import/src/client/locale/index.ts b/packages/plugins/import/src/client/locale/index.ts new file mode 100644 index 000000000..0b1468e94 --- /dev/null +++ b/packages/plugins/import/src/client/locale/index.ts @@ -0,0 +1,3 @@ +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; + diff --git a/packages/plugins/import/src/client/locale/zh-CN.ts b/packages/plugins/import/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..95ceeffd3 --- /dev/null +++ b/packages/plugins/import/src/client/locale/zh-CN.ts @@ -0,0 +1,23 @@ +export default { + 'Only one file is allowed to be uploaded': '只允许上传一个文件', + 'File size cannot exceed 10M': '文件大小不能超过10M', + 'Please upload the file of Excel': '请上传Excel的文件', + 'Import Data': '导入数据', + 'Start import': '开始导入', + 'Import explain': '说明', + 'Download template': '下载模板', + 'Step 1: Download template': '1.下载模板', + 'Step 2: Upload Excel': '2.上传完善后的表格', + 'Download tip': + '- 下载模板后,按格式填写数据\r\n - 只导入第一张工作表\r\n - 支持单次导入不超过10000行数据\r\n - 请勿改模板表头,防止导入失败', + 'Upload placeholder': '将文件拖曳到此处或点击上传,文件大小不超过10M', + 'Excel data importing': '数据导入中,请勿关闭窗口', + 'Import done, total success have {{successCount}} , total failure have {{failureCount}}': + '导入完成,共导入成功{{successCount}}条数据,共导入失败{{failureCount}}条数据', + 'To download the failure data': '下载导入失败的数据', + 'Add importable field': '添加可导入字段', + Done: '完成', + Yes: '是', + No: '否', + 'Field {{fieldName}} does not exist': '字段 {{fieldName}} 不存在', +}; diff --git a/packages/plugins/import/src/client/useFields.ts b/packages/plugins/import/src/client/useFields.ts new file mode 100644 index 000000000..a4cd3a10a --- /dev/null +++ b/packages/plugins/import/src/client/useFields.ts @@ -0,0 +1,52 @@ +import { useCollectionManager } from '@nocobase/client'; + +const EXCLUDE_INTERFACES = [ + 'icon', + 'formula', + 'attachment', + 'markdown', + 'richText', + 'id', + 'createdAt', + 'createdBy', + 'updatedAt', + 'updatedBy', + 'sequence', +]; + +export const useFields = (collectionName: string) => { + const { getCollectionFields } = useCollectionManager(); + const fields = getCollectionFields(collectionName); + const field2option = (field, depth) => { + if (!field.interface || EXCLUDE_INTERFACES.includes(field.interface)) { + return; + } + const option = { + name: field.name, + title: field?.uiSchema?.title || field.name, + schema: field?.uiSchema, + }; + if (!field.target || depth >= 2) { + return option; + } + + if (field.target) { + const targetFields = getCollectionFields(field.target); + const options = getOptions(targetFields, depth + 1).filter(Boolean); + option['children'] = option['children'] || []; + option['children'].push(...options); + } + return option; + }; + const getOptions = (fields, depth) => { + const options = []; + fields.forEach((field) => { + const option = field2option(field, depth); + if (option) { + options.push(option); + } + }); + return options; + }; + return getOptions(fields, 1); +}; diff --git a/packages/plugins/import/src/client/useImportAction.ts b/packages/plugins/import/src/client/useImportAction.ts new file mode 100644 index 000000000..af1d4f420 --- /dev/null +++ b/packages/plugins/import/src/client/useImportAction.ts @@ -0,0 +1,134 @@ +import { Schema, useFieldSchema, useForm } from '@formily/react'; +import { isEmpty } from '@formily/shared'; +import { + useActionContext, + useAPIClient, + useBlockRequestContext, + useCollection, + useCollectionManager, + useCompile, +} from '@nocobase/client'; +import { message } from 'antd'; +import { saveAs } from 'file-saver'; +import { cloneDeep } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACE } from './constants'; +import { useImportContext } from './context'; +import { ImportStatus } from './ImportModal'; + +const useImportSchema = (s: Schema) => { + let schema = s; + while (schema && schema['x-action'] !== 'import') { + schema = schema.parent; + } + return { schema }; +}; + +export const useDownloadXlsxTemplateAction = () => { + const { service, resource } = useBlockRequestContext(); + const apiClient = useAPIClient(); + const actionSchema = useFieldSchema(); + const compile = useCompile(); + const { getCollectionJoinField } = useCollectionManager(); + const { name, title, getField } = useCollection(); + const { t } = useTranslation(NAMESPACE); + const { schema: importSchema } = useImportSchema(actionSchema); + return { + async run() { + const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {}); + try { + importColumns.forEach((es) => { + const { uiSchema, interface: fieldInterface } = + getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {}; + if (isEmpty(uiSchema) && isEmpty(fieldInterface)) { + throw new Error(t('Field {{fieldName}} does not exist', { fieldName: es.dataIndex.join('.') })); + } + es.enum = uiSchema?.enum?.map((e) => ({ value: e.value, label: e.label })); + if (!es.enum && uiSchema.type === 'boolean') { + es.enum = [ + { value: true, label: t('Yes') }, + { value: false, label: t('No') }, + ]; + } + es.defaultTitle = compile(uiSchema?.title); + if (fieldInterface === 'chinaRegion') { + es.dataIndex.push('name'); + } + }); + } catch (error) { + message.error(error.message); + return; + } + + const { data } = await resource.downloadXlsxTemplate( + { + title: compile(title), + explain, + columns: JSON.stringify(compile(importColumns)), + }, + { + method: 'get', + responseType: 'blob', + }, + ); + let blob = new Blob([data], { type: 'application/x-xls' }); + saveAs(blob, `${compile(title)}.xlsx`); + }, + }; +}; + +export const useImportStartAction = () => { + const { service, resource } = useBlockRequestContext(); + const apiClient = useAPIClient(); + const actionSchema = useFieldSchema(); + const compile = useCompile(); + const { getCollectionJoinField } = useCollectionManager(); + const { name, title, getField } = useCollection(); + const { t } = useTranslation(NAMESPACE); + const { schema: importSchema } = useImportSchema(actionSchema); + const form = useForm(); + const { setVisible } = useActionContext(); + const { setImportModalVisible, setImportStatus, setImportResult } = useImportContext(); + return { + async run() { + const { importColumns, explain } = cloneDeep(importSchema?.['x-action-settings']?.['importSettings'] ?? {}); + try { + importColumns.forEach((es) => { + const { uiSchema, interface: fieldInterface } = + getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {}; + if (isEmpty(uiSchema) && isEmpty(fieldInterface)) { + throw new Error(t('Field {{fieldName}} does not exist', { fieldName: es.dataIndex.join('.') })); + } + es.enum = uiSchema?.enum?.map((e) => ({ value: e.value, label: e.label })); + if (!es.enum && uiSchema.type === 'boolean') { + es.enum = [ + { value: true, label: t('Yes') }, + { value: false, label: t('No') }, + ]; + } + es.defaultTitle = compile(uiSchema?.title); + if (fieldInterface === 'chinaRegion') { + es.dataIndex.push('name'); + } + }); + } catch (error) { + message.error(error.message); + return; + } + let formData = new FormData(); + const uploadFiles = form.values.upload.map((f) => f.originFileObj); + console.log(form, uploadFiles); + formData.append('file', uploadFiles[0]); + formData.append('columns', JSON.stringify(importColumns)); + formData.append('explain', explain); + setVisible(false); + setImportModalVisible(true); + setImportStatus(ImportStatus.IMPORTING); + const { data }: any = await apiClient.axios.post(`${name}:importXlsx`, formData, {}).catch((err) => {}); + setImportResult(data); + form.reset(); + await service?.refresh?.(); + setImportStatus(ImportStatus.IMPORTED); + }, + }; +}; diff --git a/packages/plugins/import/src/client/useShared.ts b/packages/plugins/import/src/client/useShared.ts new file mode 100644 index 000000000..2d56f4587 --- /dev/null +++ b/packages/plugins/import/src/client/useShared.ts @@ -0,0 +1,131 @@ +import { css } from '@emotion/css'; +import type { VoidField } from '@formily/core'; +import { useCollection } from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACE } from './constants'; +import { useFields } from './useFields'; + +const INCLUDE_FILE_TYPE = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', +]; + +export const useShared = () => { + const { t } = useTranslation(NAMESPACE); + const { name } = useCollection(); + const fields = useFields(name); + return { + importSettingsSchema: { + type: 'void', + 'x-component': 'Grid', + properties: { + explain: { + type: 'string', + title: `{{ t("Import explain", {ns: "${NAMESPACE}"}) }}`, + 'x-decorator': 'FormItem', + 'x-component': 'Input.TextArea', + }, + importColumns: { + type: 'array', + 'x-component': 'ArrayItems', + 'x-decorator': 'FormItem', + items: { + type: 'object', + properties: { + space: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + className: css` + width: 100%; + & .ant-space-item:nth-child(2) { + flex: 1; + } + `, + }, + properties: { + sort: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.SortHandle', + }, + dataIndex: { + type: 'array', + 'x-decorator': 'FormItem', + 'x-component': 'Cascader', + required: true, + enum: fields, + 'x-component-props': { + fieldNames: { + label: 'title', + value: 'name', + children: 'children', + }, + // labelInValue: true, + changeOnSelect: false, + }, + }, + remove: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.Remove', + }, + }, + }, + }, + }, + properties: { + add: { + type: 'void', + title: `{{ t("Add importable field", {ns: "${NAMESPACE}"}) }}`, + 'x-component': 'ArrayItems.Addition', + 'x-component-props': { + className: css` + border-color: rgb(241, 139, 98); + color: rgb(241, 139, 98); + &.ant-btn-dashed:hover { + border-color: rgb(241, 139, 98); + color: rgb(241, 139, 98); + } + `, + }, + }, + }, + }, + }, + }, + beforeUploadHandler() { + return false; + }, + uploadValidator(value, rule) { + if (value.length > 1) { + return { + type: 'error', + message: t('Only one file is allowed to be uploaded'), + }; + } + const file = value[0] ?? {}; + if (file.size > 10 * 1024 * 1024) { + return { + type: 'error', + message: t('File size cannot exceed 10M'), + }; + } + if (!INCLUDE_FILE_TYPE.includes(file.type)) { + return { + type: 'error', + message: t('Please upload the file of Excel'), + }; + } + return ''; + }, + validateUpload(form, submitField: VoidField, deps) { + const [upload] = deps; + submitField.disabled = upload?.length === 0; + submitField.componentProps = { + ...submitField.componentProps, + disabled: upload?.length === 0 || form.errors?.length > 0, + }; + }, + }; +}; diff --git a/packages/plugins/import/src/index.ts b/packages/plugins/import/src/index.ts new file mode 100644 index 000000000..6ccdba195 --- /dev/null +++ b/packages/plugins/import/src/index.ts @@ -0,0 +1,2 @@ +export { default } from './server'; +export const namespace = require('../package.json').name; diff --git a/packages/plugins/import/src/server/actions/.gitkeep b/packages/plugins/import/src/server/actions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/import/src/server/actions/downloadXlsxTemplate.ts b/packages/plugins/import/src/server/actions/downloadXlsxTemplate.ts new file mode 100644 index 000000000..f917d4f27 --- /dev/null +++ b/packages/plugins/import/src/server/actions/downloadXlsxTemplate.ts @@ -0,0 +1,30 @@ +import { Context, Next } from '@nocobase/actions'; +import xlsx from 'node-xlsx'; + +export async function downloadXlsxTemplate(ctx: Context, next: Next) { + let { columns, explain, title } = ctx.action.params; + if (typeof columns === 'string') { + columns = JSON.parse(columns); + } + const header = columns?.map((column) => column.defaultTitle); + const data = [header]; + if (explain?.trim() !== '') { + data.push([explain]); + } + + ctx.body = xlsx.build([ + { + name: title, + data, + options: {}, + }, + ]); + + ctx.set({ + 'Content-Type': 'application/octet-stream', + // to avoid "invalid character" error in header (RFC) + 'Content-Disposition': `attachment; filename=${encodeURI(title)}.xlsx`, + }); + + await next(); +} diff --git a/packages/plugins/import/src/server/actions/importXlsx.ts b/packages/plugins/import/src/server/actions/importXlsx.ts new file mode 100644 index 000000000..f74fe85bd --- /dev/null +++ b/packages/plugins/import/src/server/actions/importXlsx.ts @@ -0,0 +1,99 @@ +import { Context, Next } from '@nocobase/actions'; +import { Repository } from '@nocobase/database'; +import { cloneDeep } from 'lodash'; +import xlsx from 'node-xlsx'; +import { transform } from '../utils'; + +const IMPORT_LIMIT_COUNT = 10000; + +export async function importXlsx(ctx: Context, next: Next) { + let { columns } = ctx.request.body; + const { ['file']: file } = ctx; + const { resourceName, resourceOf } = ctx.action; + if (typeof columns === 'string') { + columns = JSON.parse(columns); + } + const repository = ctx.db.getRepository(resourceName, resourceOf) as Repository; + const collection = repository.collection; + + columns = columns?.filter((col) => col?.dataIndex?.length > 0); + const collectionFields = columns.map((col) => collection.fields.get(col.dataIndex[0])); + const { + 0: { data: originalList }, + } = xlsx.parse(file.buffer); + const failureData = originalList.splice(IMPORT_LIMIT_COUNT + 1); + const titles = originalList.shift(); + const legalList = []; + if (originalList.length > 0 && titles?.length === columns.length) { + // const results = ( + // await Promise.allSettled( + // originalList.map(async (item) => { + // try { + // const transformResult = await transform({ ctx, record: item, columns, fields: collectionFields }); + // legalList.push(cloneDeep(item)); + // return transformResult; + // } catch (error) { + // failureData.unshift([...item, error.message]); + // } + // }), + // ) + // ).filter((item) => 'value' in item && item.value !== undefined); + const values = []; + for (const item of originalList) { + try { + const transformResult = await transform({ ctx, record: item, columns, fields: collectionFields }); + values.push(transformResult); + legalList.push(cloneDeep(item)); + } catch (error) { + failureData.unshift([...item, error.message]); + } + } + //@ts-ignore + // const values = results.map((r) => r.value); + const result = await ctx.db.sequelize.transaction(async (transaction) => { + for (const [index, val] of values.entries()) { + if (val === undefined || val === null) { + continue; + } + try { + await repository.create({ + values: { ...val }, + transaction, + }); + } catch (error) { + const failData = legalList[index]; + failData.push(error?.original?.message ?? error.message); + failureData.unshift(failData); + } + } + return { + successCount: originalList.length - failureData.length, + failureCount: failureData.length, + }; + }); + const header = columns?.map((column) => column.defaultTitle); + ctx.body = { + rows: xlsx.build([ + { + name: file.originalname, + data: [header].concat(failureData), + }, + ]), + ...result, + }; + } else { + ctx.body = { + rows: file.buffer.toJSON(), + successCount: 0, + failureCount: originalList?.length ?? 0, + }; + } + + ctx.set({ + 'Content-Type': 'application/octet-stream', + // to avoid "invalid character" error in header (RFC) + 'Content-Disposition': `attachment; filename=${encodeURI('testTitle')}.xlsx`, + }); + + await next(); +} diff --git a/packages/plugins/import/src/server/actions/index.ts b/packages/plugins/import/src/server/actions/index.ts new file mode 100644 index 000000000..79fbffbf5 --- /dev/null +++ b/packages/plugins/import/src/server/actions/index.ts @@ -0,0 +1,2 @@ +export * from './downloadXlsxTemplate'; +export * from './importXlsx'; diff --git a/packages/plugins/import/src/server/collections/.gitkeep b/packages/plugins/import/src/server/collections/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/import/src/server/index.ts b/packages/plugins/import/src/server/index.ts new file mode 100644 index 000000000..fe85300b9 --- /dev/null +++ b/packages/plugins/import/src/server/index.ts @@ -0,0 +1,45 @@ +import { InstallOptions, Plugin } from '@nocobase/server'; +import { namespace } from '..'; +import { downloadXlsxTemplate, importXlsx } from './actions'; +import { enUS, zhCN } from './locale'; +import { importMiddleware } from './middleware'; + +export class ImportPlugin extends Plugin { + + beforeLoad() { + this.app.i18n.addResources('zh-CN', namespace, zhCN); + this.app.i18n.addResources('en-US', namespace, enUS); + } + + async load() { + // Visit: http://localhost:13000/api/import:importXlsx + this.app.resourcer.use(importMiddleware); + this.app.resourcer.registerActionHandler('downloadXlsxTemplate', downloadXlsxTemplate); + this.app.resourcer.registerActionHandler('importXlsx', importXlsx); + // this.app.resource({ + // name: 'import', + // actions: { + // importXlsx, + // }, + // }); + this.app.acl.setAvailableAction('importXlsx', { + displayName: '{{t("Import")}}', + allowConfigureFields: true, + }); + this.app.acl.use(async (ctx, next) => { + const { actionName } = ctx.action; + if (['downloadXlsxTemplate', 'importXlsx'].includes(actionName)) { + ctx.permission = { + skip: true, + }; + } + await next(); + }); + } + + async install(options: InstallOptions) { + // TODO + } +} + +export default ImportPlugin; diff --git a/packages/plugins/import/src/server/locale/en-US.ts b/packages/plugins/import/src/server/locale/en-US.ts new file mode 100644 index 000000000..612d96de9 --- /dev/null +++ b/packages/plugins/import/src/server/locale/en-US.ts @@ -0,0 +1,10 @@ +export default { + Yes: 'Yes', + No: 'No', + 'can not find value': 'can not find value', + 'password is empty': 'password is empty', + 'Incorrect time format': 'Incorrect time format', + 'Incorrect date format': 'Incorrect date format', + 'Incorrect email format': 'Incorrect email format', + 'Illegal percentage format': 'Illegal percentage format', +}; diff --git a/packages/plugins/import/src/server/locale/index.ts b/packages/plugins/import/src/server/locale/index.ts new file mode 100644 index 000000000..0b1468e94 --- /dev/null +++ b/packages/plugins/import/src/server/locale/index.ts @@ -0,0 +1,3 @@ +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; + diff --git a/packages/plugins/import/src/server/locale/zh-CN.ts b/packages/plugins/import/src/server/locale/zh-CN.ts new file mode 100644 index 000000000..a69977251 --- /dev/null +++ b/packages/plugins/import/src/server/locale/zh-CN.ts @@ -0,0 +1,10 @@ +export default { + Yes: '是', + No: '否', + 'can not find value': '找不到对应值', + 'password is empty': '密码为空', + 'Incorrect time format': '时间格式不正确', + 'Incorrect date format': '日期格式不正确', + 'Incorrect email format': '邮箱格式不正确', + 'Illegal percentage format': '百分比格式有误', +}; diff --git a/packages/plugins/import/src/server/middleware/index.ts b/packages/plugins/import/src/server/middleware/index.ts new file mode 100644 index 000000000..9cbe99e6f --- /dev/null +++ b/packages/plugins/import/src/server/middleware/index.ts @@ -0,0 +1,10 @@ +import multer from '@koa/multer'; +import { Context, Next } from '@nocobase/actions'; + +export async function importMiddleware(ctx: Context, next: Next) { + if (ctx.action.actionName !== 'importXlsx') { + return next(); + } + const upload = multer().single('file'); + return upload(ctx, next); +} diff --git a/packages/plugins/import/src/server/models/.gitkeep b/packages/plugins/import/src/server/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/import/src/server/repositories/.gitkeep b/packages/plugins/import/src/server/repositories/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/import/src/server/utils/index.ts b/packages/plugins/import/src/server/utils/index.ts new file mode 100644 index 000000000..ecacaa4af --- /dev/null +++ b/packages/plugins/import/src/server/utils/index.ts @@ -0,0 +1,20 @@ +import { set } from 'lodash'; +import * as transforms from './transform'; + +function getTransform(name: string): Function { + return transforms[name] || transforms._; +} + +export async function transform({ ctx, record, columns, fields }) { + const newRecord = {}; + for (let index = 0, iLen = record.length; index < iLen; index++) { + const cell = record[index]; + const column = columns[index] ?? {}; + const { dataIndex } = column; + const field = fields.find((f) => f.name === dataIndex[0]); + const t = getTransform(field.options.interface); + const value = await t({ ctx, column, value: cell, field }); + set(newRecord, dataIndex[0], value); + } + return newRecord; +} diff --git a/packages/plugins/import/src/server/utils/transform.ts b/packages/plugins/import/src/server/utils/transform.ts new file mode 100644 index 000000000..9706f88a5 --- /dev/null +++ b/packages/plugins/import/src/server/utils/transform.ts @@ -0,0 +1,160 @@ +import { str2moment } from '@nocobase/utils'; +import * as math from 'mathjs'; +import moment from 'moment'; +import { namespace } from '../../'; + +export async function _({ value, field }) { + return value; +} + +export async function email({ value, field, ctx }) { + if (!value?.trim()) { + return value; + } + const emailReg = /^([a-zA-Z0-9._-])+@([a-zA-Z0-9_-])+(\.[a-zA-Z0-9_-])+/; + if (!emailReg.test(value)) { + throw new Error(ctx.t('Incorrect email format', { ns: namespace })); + } + return value; +} + +export async function password({ value, field, ctx }) { + if (value === undefined || value === null) { + throw new Error(ctx.t('password is empty', { ns: namespace })); + } + return `${value}`; +} + +export async function o2o({ value, column, field, ctx }) { + const { dataIndex, enum: enumData } = column; + const repository = ctx.db.getRepository(field.options.target); + let enumItem = null; + if (enumData?.length > 0) { + enumItem = enumData.find((e) => e.label === value); + } + const val = await repository.findOne({ filter: { [dataIndex[1]]: enumItem?.value ?? value } }); + return val; +} +export const oho = o2o; +export const obo = o2o; + +export async function o2m({ value, column, field, ctx }) { + let results = []; + const values = value.split(';').map((val) => val.trim()); + const { dataIndex, enum: enumData } = column; + const repository = ctx.db.getRepository(field.options.target); + if (enumData?.length > 0) { + const enumValues = values.map((val) => { + const v = enumData.find((e) => e.label === val); + if (v === undefined) { + throw new Error(`not found enum value ${val}`); + } + return v.value; + }); + results = await repository.find({ filter: { [dataIndex[1]]: enumValues } }); + } else { + results = await repository.find({ filter: { [dataIndex[1]]: values } }); + } + return results; +} + +export async function m2o({ value, column, field, ctx }) { + let results = null; + const { dataIndex, enum: enumData } = column; + const repository = ctx.db.getRepository(field.options.target); + if (enumData?.length > 0) { + const enumValue = enumData.find((e) => e.label === value?.trim())?.value; + results = await repository.findOne({ filter: { [dataIndex[1]]: enumValue } }); + } else { + results = await repository.findOne({ filter: { [dataIndex[1]]: value } }); + } + return results; +} + +export async function m2m({ value, column, field, ctx }) { + let results = []; + const values = value.split(';').map((val) => val.trim()); + const { dataIndex, enum: enumData } = column; + const repository = ctx.db.getRepository(field.options.target); + if (enumData?.length > 0) { + const enumValues = values.map((val) => { + const v = enumData.find((e) => e.label === val); + if (v === undefined) { + throw new Error(`not found enum value ${val}`); + } + return v.value; + }); + results = await repository.find({ filter: { [dataIndex[1]]: enumValues } }); + } else { + results = await repository.find({ filter: { [dataIndex[1]]: values } }); + } + return results; +} +export async function datetime({ value, field, ctx }) { + if (!value) { + return ''; + } + const utcOffset = ctx.get('X-Timezone'); + const props = field.options?.uiSchema?.['x-component-props'] ?? {}; + const m = str2moment(value, { ...props, utcOffset }); + if (!m.isValid()) { + throw new Error(ctx.t('Incorrect date format', { ns: namespace })); + } + return m.toDate(); +} +export async function time({ value, field, ctx }) { + const { format } = field.options?.uiSchema?.['x-component-props'] ?? {}; + if (format) { + const m = moment(value, format); + if (!m.isValid()) { + throw new Error(ctx.t('Incorrect time format', { ns: namespace })); + } + return m.format(format); + } + return value; +} +export async function percent({ value, field, ctx }) { + if (value) { + const numberValue = Number(value?.split('%')?.[0] ?? value); + if (isNaN(numberValue)) { + throw new Error(ctx.t('Illegal percentage format', { ns: namespace })); + } + return math.round(numberValue / 100, 9); + } + return 0; +} +export async function checkbox({ value, column, field, ctx }) { + return value === ctx.t('Yes', { ns: namespace }) ? 1 : 0; +} + +export const boolean = checkbox; + +export async function select({ value, column, field, ctx }) { + const { enum: enumData } = column; + const item = enumData.find((item) => item.label === value); + return item?.value; +} +export const radio = select; + +export const radioGroup = select; + +export async function multipleSelect({ value, column, field, ctx }) { + const values = value?.split(';'); + const { enum: enumData } = column; + const results = values?.map((val) => { + const item = enumData.find((item) => item.label === val); + return item; + }); + return results?.map((result) => result?.value); +} + +export const checkboxes = multipleSelect; + +export const checkboxGroup = multipleSelect; + +export async function chinaRegion({ value, column, field, ctx }) { + const values = value?.split('/')?.map((val) => val.trim()); + const repository = ctx.db.getRepository('chinaRegions'); + const results = await repository.find({ filter: { name: values } }); + return results; +} diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 2eede5ca2..4b23d8a06 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -18,6 +18,7 @@ "@nocobase/plugin-collection-manager": "0.8.0-alpha.1", "@nocobase/plugin-error-handler": "0.8.0-alpha.1", "@nocobase/plugin-export": "0.8.0-alpha.1", + "@nocobase/plugin-import": "0.8.0-alpha.1", "@nocobase/plugin-file-manager": "0.8.0-alpha.1", "@nocobase/plugin-system-settings": "0.8.0-alpha.1", "@nocobase/plugin-ui-routes-storage": "0.8.0-alpha.1", diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index 9762f0f8a..115b5bc09 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -16,6 +16,7 @@ export class PresetNocoBase extends Plugin { 'workflow', 'client', 'export', + 'import', 'audit-logs', ]; await this.app.pm.add(plugins, {