Some features (#979)

* feat: add import client

* feat: add import server

* refactor: change export use library of file-saver

* refactor: upload excel file done

* refactor: upload xls transform

* feat: upload ui done

* feat: exclude unable import fields

* feat: excel file validator done

* feat: import done

* feat: import transform done

* fix: add import plugin in presets

* fix: explain will not output in template what is empty

* fix: config permission

* fix: permission skip

* fix: import password must be string

* fix: done close Modal

* fix: loop through, inserting data item by item

* fix: number calc with using mathjs

* fix: import plugin add locale

* fix: fix some bugs

* feat: bulk update done

* fix: transaction cannot be rolled back because it has been finished with state: rollback

* fix(plugin-system-settings): convert array to json

* fix(collection-manager): o2m is array type

* fix: missing RefreshActionInitializer

* fix(collection-manger): incorrect scope key parameter

* fix: can't access pages without permission via url (#826)

* feat(database): add sequence field type (#779)

* feat(database): add serialString field type

* feat(database): add serial string type field ui (skip ci)

* test(feat/database): test field options

* docs: demo

* fix(database): fix array table field behavior

* fix(database): fix serial type interface ui

* fix(database): add match logic for patterns changes

* fix(database): fix serial type query last bug in mysql

* refactor(database): refactor last record logic

* chore: revert modification on unnecessary file

* refactor(database): rename serialString type to sequence

Co-authored-by: chenos <chenlinxh@gmail.com>

* added Russian translation (#840)

* Russian translation

* Add files via upload

Add RU locale into index.ts
Bugs fixed in the ru_RU.ts

* Update index.ts

Correct lines 4 and 8

* feat: update option must have filter or filterByTk (#847)

* feat: update option must have filter or filterByTk

* fix: typo

* fix: typo

* feat(core/cache): support cache (#876)

* feat(core/cache): support cache

* build(create-nocobase-app): remove --cache-store-package cli option

* perf(core/cache): modify default cache config and remove unnecessary logic code

* fix: slow join query issued by appends field in find method of repository  (#845)

* fix: slow join query issue by appends field in repository.find

* feat: handle appending query in multiple relation repository

* feat: handle appending query in single relation repository

Co-authored-by: chenos <chenlinxh@gmail.com>

* fix: sort parameter is missing (#849)

* fix: 审计日志翻页sort丢失

* fix: 审计日志翻页sort丢失

* fix: 审计日志翻页sort丢失

Co-authored-by: 唐小爱 <tangxiaoai@192.168.0.103>

* fix(formula): support integer and fix NaN error (#879)

* fix(formula): support integer and fix NaN error

* style(formula-input): remove debugger

* fix(database): fix the index name too long error

* feat(collection-manager): inverse fields can be configured (#883)

* feat: inverse field

* feat: improve code

* feat: translations

* fix: required

* fix: run test by jest (#891)

* fix: unable to submit form during file upload (#892)

* fix(client/block-select-collection): fix select collection menu view error (#889)

* fix(client/block-select-collection): fix too many collection menu view error

* fix(client/relate-collection-field-menu): fix relate collection field menu view too long error

* fix(client/record-picker): support record-picker show format DataPicker (#888)

* fix(client/record-picker): support record-picker show format DataPicker

* fix(client/record-picker): undefined judgment and when change field's label refresh format in time

* feat: improve signin and signup page components

* feat(plugin-workflow): add concat calculator (#894)

* fix: single relation repository appends query issue (#901)

* fix: appends merge includes (#905)

* fix: build error

* fix(client): tab pane initializers for create form block

* fix: version judgment is not accurate

* fix: sync collection field default value (#907)

* feat: limit database identifier (#908)

* fix: cannot read properties of undefined (reading 'target')

* fix: appends merge now using primary key (#911)

* fix: appends merge now using primary key

* chore: console.log

* fix: unbind on error throwing (#914)

* feat: create with array of values (#912)

* feat: create with array of values

* chore: console.log

* chore: debug

* fix(client/route-switch): skip sub routes

* Feat: plugin workflow collection field (#919)

* feat(plugin-workflow): use Collectionfield component to render form

* fix(plugin-workflow): fix association types value assigning in nodes

* fix: missing menuItemGroupCss

* fix: multiple = false

* chore(versions): 😊 publish v0.7.5-alpha.1 (#920)

* fix(plugin-workflow): temp disable validation of collection field in node (#928)

* fix(plugin-workflow): fix schedule infinitely trigger when repeat not set (#926)

* Feat/plugin workflow collection field (#934)

* feat(plugin-workflow): support association constant simple input

* fix(plugin-workflow): remove useless code

* fix(plugin-workflow): add req context to processor (#936)

* feat: bulk update done

* feat: bulk edit done

* fix: fix import bug

* Update database.ts

* fix: workflow

* fix: error

* fix: plugin-import

* fix: handle locale

* fix: handle locale

* fix: allow email is undefined

* fix: action add loading

* fix: fix import bug

* fix: not allow sequence import

* fix: remove field not allow download template

* fix: remove field not allow download template

* fix: checkbox batch edit error

* fix: fix build edit

Co-authored-by: Semmy <semmywong@126.com>
Co-authored-by: Junyi <mytharcher@users.noreply.github.com>
Co-authored-by: arzanov <59161748+arzanov@users.noreply.github.com>
Co-authored-by: ChengLei Shao <chareice@live.com>
Co-authored-by: lyf-coder <58352715+lyf-coder@users.noreply.github.com>
Co-authored-by: katherinehhh <shunai.tang@hand-china.com>
Co-authored-by: 唐小爱 <tangxiaoai@192.168.0.103>
This commit is contained in:
chenos 2022-10-31 23:22:33 +08:00 committed by GitHub
parent 31815c54be
commit 89af2175de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 2222 additions and 54 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-import/client';

View File

@ -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();

View File

@ -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),
}
};
}
}
}

View File

@ -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"
}

View File

@ -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": "请选择要更新的记录"
}

View File

@ -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<ISchema>();
const actionType = fieldSchema['x-action'] || '';
@ -152,6 +153,26 @@ export const ActionDesigner = (props) => {
}}
/>
)}
{isUpdateModePopupAction && (
<SchemaSettings.SelectItem
title={t('Data will be updated')}
options={[
{ label: t('Selected'), value: 'selected' },
{ label: t('All'), value: 'all' },
]}
value={fieldSchema?.['x-action-settings']?.['updateMode']}
onChange={(value) => {
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) && (
<SchemaSettings.ActionModalItem
@ -247,6 +268,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]
}
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: {

View File

@ -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 && <RecursionField basePath={field.address} onlyRenderProperties schema={fieldSchema} />}

View File

@ -1,3 +1,4 @@
import { Schema } from '@formily/react';
import { createContext } from 'react';
export const ActionContext = createContext<ActionContextProps>({});
@ -10,4 +11,5 @@ export interface ActionContextProps {
containerRefKey?: string;
formValueChanged?: boolean;
setFormValueChanged?: (v: boolean) => void;
fieldSchema?: Schema;
}

View File

@ -12,7 +12,12 @@ type ComposedCheckbox = React.FC<CheckboxProps> & {
};
export const Checkbox: ComposedCheckbox = connect(
AntdCheckbox,
(props: any) => {
const changeHandler = (val) => {
props?.onChange(val);
};
return <AntdCheckbox {...props} onChange={changeHandler} />;
},
mapProps(
{
value: 'checked',

View File

@ -37,6 +37,7 @@ export const FilterActionDesigner = (props) => {
const checked = !nonfilterable.includes(field.name);
return (
<SchemaSettings.SwitchItem
key={field.name}
checked={checked}
title={compile(field?.uiSchema?.title)}
onChange={(value) => {

View File

@ -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 ? (
<MarkdownEditor
{...props}
className
defaultValue={content}
onCancel={() => {
field.editable = false;
@ -71,7 +73,7 @@ export const MarkdownVoid: any = observer((props: any) => {
}}
/>
) : (
<div className={'nb-markdown'} dangerouslySetInnerHTML={{ __html: markdown(content) }} />
<div className={cls(['nb-markdown', className])} dangerouslySetInnerHTML={{ __html: markdown(content) }} />
);
});

View File

@ -195,9 +195,13 @@ Upload.Attachment = connect((props: UploadProps) => {
Upload.Dragger = connect(
(props: DraggerProps) => {
const { tipContent } = props;
return (
<div className={usePrefixCls('upload-dragger')}>
<AntdUpload.Dragger {...useUploadProps(props)} />
<AntdUpload.Dragger {...useUploadProps(props)}>
{tipContent}
{props.children}
</AntdUpload.Dragger>
</div>
);
},

View File

@ -12,6 +12,8 @@ export type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
export type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
tipContent?: string | React.ReactNode;
children?: React.ReactNode;
};
export type ComposedUpload = React.FC<UploadProps> & {

View File

@ -27,7 +27,6 @@ export const Sortable = (props: any) => {
if (isOver) {
droppableStyle['color'] = 'rgba(241, 139, 98, .1)';
}
return React.createElement(
component || 'div',
{

View File

@ -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 (
<SchemaInitializer.Button
wrap={gridRowColWrap}
icon={'SettingOutlined'}
items={union<any>(
[
{
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')}
/>
);
};

View File

@ -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 (
<SchemaInitializer.Button
wrap={gridRowColWrap}
title={component ? null : t('Add block')}
icon={'PlusOutlined'}
insertPosition={insertPosition}
component={component}
items={[
{
type: 'itemGroup',
title: '{{t("Data blocks")}}',
children: [
{
type: 'item',
title: '{{t("Form")}}',
component: 'CreateFormBulkEditBlockInitializer',
},
],
},
{
type: 'itemGroup',
title: '{{t("Other blocks")}}',
children: [
{
type: 'item',
title: '{{t("Markdown")}}',
component: 'MarkdownBlockInitializer',
},
],
},
]}
/>
);
};

View File

@ -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 }}',
},
},
},
],
},
],
};

View File

@ -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 <TabPaneInitializers isCreate />;
};
export const TabPaneInitializersForBulkEditFormBlock = () => {
return <TabPaneInitializers isBulkEdit />;
};

View File

@ -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,
},
},
},
],
},
],
};

View File

@ -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';

View File

@ -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<Field>();
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 (
<CollectionFieldProvider name={fieldSchema.name}>
<InternalField {...props} />
</CollectionFieldProvider>
);
});
export enum BulkEditFormItemValueType {
RemainsTheSame = 1,
ChangedTo,
Clear,
AddAttach,
}
export const BulkEditField = (props: any) => {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const field = useField<Field>();
const [type, setType] = useState<number>(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 (
<Space>
<Select defaultValue={type} value={type} style={{ width: 150 }} onChange={typeChangeHandler}>
<Select.Option value={BulkEditFormItemValueType.RemainsTheSame}>{t('Remains the same')}</Select.Option>
<Select.Option value={BulkEditFormItemValueType.ChangedTo}>{t('Changed to')}</Select.Option>
<Select.Option value={BulkEditFormItemValueType.Clear}>{t('Clear')}</Select.Option>
{['subTable', 'linkTo', 'm2m', 'o2m', 'o2o', 'oho', 'obo', 'm2o'].includes(collectionField.interface) && (
<Select.Option value={BulkEditFormItemValueType.AddAttach}>{t('Add attach')}</Select.Option>
)}
</Select>
{/* XXX: Not a best practice */}
{[BulkEditFormItemValueType.ChangedTo, BulkEditFormItemValueType.AddAttach].includes(type) &&
collectionField.interface !== 'checkbox' && (
<CollectionField {...props} value={value} onChange={valueChangeHandler} style={{ minWidth: 150 }} />
// <SchemaComponent
// schema={collectionSchema}
// components={{ BulkEditCollectionField: CollectionField }}
// onlyRenderProperties
// />
)}
{[BulkEditFormItemValueType.ChangedTo, BulkEditFormItemValueType.AddAttach].includes(type) &&
collectionField.interface === 'checkbox' && <Checkbox checked={value} onChange={valueChangeHandler} />}
</Space>
);
};

View File

@ -1 +1,2 @@
export * from './assigned-field';
export * from './BulkEditField';

View File

@ -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 (
<SchemaInitializer.Item
onClick={() => {
insert({
...item.schema,
});
const s = merge(schema || {}, item.schema || {});
item?.schemaInitialize?.(s);
insert(s);
}}
/>
);

View File

@ -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 <ActionInitializer {...props} schema={schema} />;
};

View File

@ -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 (
<SchemaInitializer.Item
icon={<FormOutlined />}
{...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')}
/>
);
};

View File

@ -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 <BlockInitializer {...props} schema={schema} />;
};

View File

@ -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';

View File

@ -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,7 +351,12 @@ 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();
@ -329,7 +365,7 @@ export const useRecordCollectionDataSourceItems = (componentName, item = null, c
return componentName && template.componentName === componentName;
})
.filter((template) => {
return ['FormItem', 'ReadPrettyFormItem'].includes(componentName) || (template.resourceName === resourceName);
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 {

View File

@ -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<Field>();
const fieldSchema = useFieldSchema();
const ctx = useBlockTemplateContext();
const form = useForm();
return (
<SchemaSettings.Item
onClick={() => {
@ -403,6 +404,7 @@ SchemaSettings.Remove = (props: any) => {
} else {
dn.remove(null, options);
}
delete form.values[fieldSchema.name];
},
});
}}

View File

@ -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`);
},
};
};

4
packages/plugins/import/client.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -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];
}
});
});

View File

@ -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": "*"
}
}

4
packages/plugins/import/server.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -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];
}
});
});

View File

@ -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 (
<SchemaInitializer.SwitchItem
checked={exists}
title={item.title}
onClick={() => {
if (exists) {
return remove();
}
schema['x-action-settings']['importSettings'] = initImportSettings(fields);
const s = merge(schema || {}, item.schema || {});
item?.schemaInitialize?.(s);
insert(s);
}}
/>
);
};

View File

@ -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<ISchema>();
const { importSettingsSchema } = useShared();
useEffect(() => {
setSchema(importSettingsSchema);
}, [field.address, fieldSchema?.['x-action-settings']?.['importSettings']]);
return (
<GeneralSchemaDesigner disableInitializer>
<SchemaSettings.ModalItem
title={t('Edit button')}
schema={
{
type: 'object',
title: t('Edit button'),
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Button title'),
default: fieldSchema.title,
'x-component-props': {},
// description: `原字段标题:${collectionField?.uiSchema?.title}`,
},
icon: {
'x-decorator': 'FormItem',
'x-component': 'IconPicker',
title: t('Button icon'),
default: fieldSchema?.['x-component-props']?.icon,
'x-component-props': {},
// description: `原字段标题:${collectionField?.uiSchema?.title}`,
},
type: {
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
title: t('Button background color'),
default: fieldSchema?.['x-component-props']?.danger
? 'danger'
: fieldSchema?.['x-component-props']?.type === 'primary'
? 'primary'
: 'default',
enum: [
{ value: 'default', label: '{{t("Default")}}' },
{ value: 'primary', label: '{{t("Highlight")}}' },
{ value: 'danger', label: '{{t("Danger red")}}' },
],
},
},
} as ISchema
}
onSubmit={({ title, icon, type }) => {
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();
}
}}
/>
<SchemaSettings.ActionModalItem
title={t('Importable fields')}
schema={schema}
initialValues={{ ...(fieldSchema?.['x-action-settings']?.importSettings ?? {}) }}
components={{ ArrayItems }}
onSubmit={({ importColumns, explain }) => {
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();
}}
/>
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={(s) => {
return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar');
}}
confirm={{
title: t('Delete action'),
}}
/>
</GeneralSchemaDesigner>
);
};

View File

@ -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;
};

View File

@ -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 (
<Modal
title={t('Import Data')}
width="50%"
bodyStyle={{ height: 'calc(80vh - 200px)' }}
visible={importModalVisible}
footer={null}
closable={importStatus === ImportStatus.IMPORTED}
onCancel={doneHandler}
>
<div
className={css`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`}
>
{importStatus === ImportStatus.IMPORTING && (
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} tip={t('Excel data importing')} />
)}
{importStatus === ImportStatus.IMPORTED && (
<Space direction="vertical" align="center">
<ExclamationCircleFilled style={{ fontSize: 72, color: '#1890ff' }} />
<p>
{t('Import done, total success have {{successCount}} , total failure have {{failureCount}}', {
...(meta ?? {}),
})}
</p>
<Space>
{meta?.failureCount > 0 && (
<Button onClick={downloadFailureDataHandler}>{t('To download the failure data')}</Button>
)}
<Button type="primary" onClick={doneHandler}>
{t('Done')}
</Button>
</Space>
</Space>
)}
</div>
</Modal>
);
};

View File

@ -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 (
<SchemaComponentOptions
components={{ ImportActionInitializer, ImportDesigner }}
scope={{
uploadValidator,
validateUpload,
beforeUploadHandler,
useDownloadXlsxTemplateAction,
useImportStartAction,
}}
>
<ImportInitializerProvider>
<ImportContextProvider>{props.children}</ImportContextProvider>
</ImportInitializerProvider>
</SchemaComponentOptions>
);
};
export const ImportContextProvider = (props: any) => {
const [importModalVisible, setImportModalVisible] = useState(false);
const [importStatus, setImportStatus] = useState<number>(ImportStatus.IMPORTING);
const [importResult, setImportResult] = useState<{
data: { type: string; data: any[] };
meta: { successCount: number; failureCount: number };
}>(null);
return (
<ImportContext.Provider
value={{
importModalVisible,
setImportModalVisible,
importStatus,
setImportStatus,
importResult,
setImportResult,
}}
>
{createPortal(<ImportModal></ImportModal>, document.body)}
{props.children}
</ImportContext.Provider>
);
};

View File

@ -0,0 +1 @@
export const NAMESPACE = 'import';

View File

@ -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<ImportContextType>(null);
export const useImportContext = () => {
return useContext(ImportContext);
};

View File

@ -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';

View File

@ -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',
};

View File

@ -0,0 +1,3 @@
export { default as enUS } from './en-US';
export { default as zhCN } from './zh-CN';

View File

@ -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}} 不存在',
};

View File

@ -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);
};

View File

@ -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);
},
};
};

View File

@ -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,
};
},
};
};

View File

@ -0,0 +1,2 @@
export { default } from './server';
export const namespace = require('../package.json').name;

View File

@ -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();
}

View File

@ -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<any>(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<any>(
// 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();
}

View File

@ -0,0 +1,2 @@
export * from './downloadXlsxTemplate';
export * from './importXlsx';

View File

@ -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;

View File

@ -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',
};

View File

@ -0,0 +1,3 @@
export { default as enUS } from './en-US';
export { default as zhCN } from './zh-CN';

View File

@ -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': '百分比格式有误',
};

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -16,6 +16,7 @@ export class PresetNocoBase extends Plugin {
'workflow',
'client',
'export',
'import',
'audit-logs',
];
await this.app.pm.add(plugins, {