From e752686c7e837786a8af9d5b5e9cb5c5f5258cd3 Mon Sep 17 00:00:00 2001 From: Junyi Date: Tue, 28 Feb 2023 17:55:58 +0800 Subject: [PATCH] feat(plugin-formula): calculation with snapshot field (#1498) * feat(plugin-formula): calculation with snapshot field * fix(plugin-snapshot): fix appends calcualtion * fix(plugin-snapshot): fix appends calcualtion * fix(plugin-snapshot): fix appends calcualtion --- .../schema-component/antd/variable/Input.tsx | 2 +- .../antd/variable/JSONInput.tsx | 2 +- .../antd/variable/TextArea.tsx | 4 +- packages/core/evaluators/src/utils/index.ts | 21 +- .../formula-field/src/client/field.tsx | 1 + .../src/client/formula/Expression.tsx | 11 +- .../snapshot-field/src/client/index.tsx | 3 +- .../snapshot-field/src/client/interface.ts | 250 ++++++++++-------- .../snapshot-field/src/client/locale/zh-CN.ts | 1 + .../src/server/fields/snapshot-field.ts | 16 +- 10 files changed, 181 insertions(+), 130 deletions(-) diff --git a/packages/core/client/src/schema-component/antd/variable/Input.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx index a847337093..e3c57043d1 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -6,7 +6,7 @@ import { cx, css } from '@emotion/css'; import { useTranslation } from 'react-i18next'; import moment from 'moment'; -import { useCompile } from '../../hooks/useCompile'; +import { useCompile } from '../..'; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; diff --git a/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx index 9903ceadb0..5a60102fd1 100644 --- a/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx +++ b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx @@ -42,7 +42,7 @@ export function JSONInput(props) { return; } - const variable = `"{{${selected.join('.')}}}"`; + const variable = `{{${selected.join('.')}}}`; const { textArea } = inputRef.current.resizableTextArea; const nextValue = textArea.value.slice(0, textArea.selectionStart) + variable + textArea.value.slice(textArea.selectionEnd); diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index 6bb00fe6e5..22aaee2347 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -3,6 +3,7 @@ import { Input, Cascader, Tooltip, Button } from 'antd'; import { useForm } from '@formily/react'; import { cx, css } from '@emotion/css'; import { useTranslation } from 'react-i18next'; +import { useCompile } from '../..'; const VARIABLE_RE = /{{\s*([^{}]+)\s*}}/g; @@ -105,9 +106,10 @@ function createVariableTagHTML(variable, keyLabelMap) { export function TextArea(props) { const { value = '', scope, onChange, multiline = true, button } = props; + const compile = useCompile(); const { t } = useTranslation(); const inputRef = useRef(null); - const options = (typeof scope === 'function' ? scope() : scope) ?? []; + const options = compile((typeof scope === 'function' ? scope() : scope) ?? []); const form = useForm(); const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); const [changed, setChanged] = useState(false); diff --git a/packages/core/evaluators/src/utils/index.ts b/packages/core/evaluators/src/utils/index.ts index f8bacd72e1..c86fcc17d0 100644 --- a/packages/core/evaluators/src/utils/index.ts +++ b/packages/core/evaluators/src/utils/index.ts @@ -1,4 +1,4 @@ -import { get } from "lodash"; +import { get, cloneDeep } from "lodash"; @@ -6,11 +6,26 @@ export type Scope = { [key: string]: any }; export type Evaluator = (expression: string, scope?: Scope) => any; +function appendArrayColumn(scope, key) { + const paths = key.split('.'); + let data = scope; + for (let p = 0; p < paths.length; p++) { + const path = paths[p]; + const isIndex = path.match(/^\d+$/); + if (Array.isArray(data) && !isIndex && !data[path]) { + data[path] = data.map(item => item[path]); + } + data = data[path]; + } +} + export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) { + const context = cloneDeep(scope); const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => { - const item = get(scope, v); + appendArrayColumn(context, v); + const item = get(context, v); const key = v.replace(/\.(\d+)/g, '["$1"]'); return ` ${typeof item === 'function' ? item() : key} `; }); - return this(exp, scope); + return this(exp, context); } diff --git a/packages/plugins/formula-field/src/client/field.tsx b/packages/plugins/formula-field/src/client/field.tsx index c717f33f7a..7a96182512 100644 --- a/packages/plugins/formula-field/src/client/field.tsx +++ b/packages/plugins/formula-field/src/client/field.tsx @@ -162,6 +162,7 @@ export default { 'select', 'multipleSelect', + 'snapshot' // 'json' ], useCurrentFields: '{{ useCurrentFields }}', diff --git a/packages/plugins/formula-field/src/client/formula/Expression.tsx b/packages/plugins/formula-field/src/client/formula/Expression.tsx index ce16d3c1e5..61603e5f30 100644 --- a/packages/plugins/formula-field/src/client/formula/Expression.tsx +++ b/packages/plugins/formula-field/src/client/formula/Expression.tsx @@ -1,17 +1,20 @@ import React from 'react'; -import { useCompile, Variable } from '@nocobase/client'; +import { useCollectionManager, useCompile, Variable } from '@nocobase/client'; export const Expression = (props) => { - const { value = '', supports, useCurrentFields, onChange } = props; + const { value = '', supports = [], useCurrentFields, onChange } = props; const compile = useCompile(); + const { interfaces } = useCollectionManager(); - const fields = useCurrentFields().filter(field => supports.includes(field.interface)); + const fields = (useCurrentFields?.() ?? []) + .filter(field => supports.includes(field.interface)); const options = fields.map(field => ({ label: compile(field.uiSchema.title), - value: field.name + value: field.name, + children: interfaces[field.interface].usePathOptions?.(field) })); return ( diff --git a/packages/plugins/snapshot-field/src/client/index.tsx b/packages/plugins/snapshot-field/src/client/index.tsx index 7c158c773d..155bfafffa 100644 --- a/packages/plugins/snapshot-field/src/client/index.tsx +++ b/packages/plugins/snapshot-field/src/client/index.tsx @@ -7,7 +7,7 @@ import { SchemaInitializerProvider, } from '@nocobase/client'; import React, { useContext, useEffect } from 'react'; -import { useSnapshotInterface } from './interface'; +import { snapshot } from './interface'; import { SnapshotRecordPicker } from './SnapshotRecordPicker'; import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers'; import { SnapshotBlockInitializersDetailItem } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializersDetailItem'; @@ -17,7 +17,6 @@ import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerC export default React.memo((props) => { const initializers = useContext(SchemaInitializerContext); - const snapshot = useSnapshotInterface(); useEffect(() => { registerField(snapshot.group, snapshot.name as string, snapshot); diff --git a/packages/plugins/snapshot-field/src/client/interface.ts b/packages/plugins/snapshot-field/src/client/interface.ts index 5945070088..9c3201aaa0 100644 --- a/packages/plugins/snapshot-field/src/client/interface.ts +++ b/packages/plugins/snapshot-field/src/client/interface.ts @@ -1,9 +1,8 @@ import type { Field } from '@formily/core'; import { ISchema } from '@formily/react'; -import { IField, interfacesProperties, useRecord } from '@nocobase/client'; +import { IField, interfacesProperties, useCollectionManager, useRecord } from '@nocobase/client'; import { cloneDeep } from 'lodash'; -import { useMemo } from 'react'; -import { useSnapshotTranslation } from './locale'; +import { NAMESPACE } from './locale'; const { defaultProps } = interfacesProperties; @@ -27,114 +26,151 @@ const onTargetFieldChange = (field: Field) => { !targetField.getState().disabled && targetField.setValue([]); }; -export const useSnapshotInterface = () => { - const { t } = useSnapshotTranslation(); +function makeFieldsPathOptions(fields, appends = []) { + const { getCollection } = useCollectionManager(); + const options = []; + fields.forEach(field => { + if (['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)) { + const currentAppends = appends.filter(key => `${key}.`.startsWith(`${field.name}.`)) + if (currentAppends.length) { + const nextCollection = getCollection(field.target); + const nextAppends = currentAppends + .filter(key => key !== field.name) + .map(key => key.replace(`${field.name}.`, '')) + .filter(key => key); + options.push({ + label: field.uiSchema?.title ?? field.name, + value: field.name, + children: makeFieldsPathOptions(nextCollection.fields, nextAppends), + }); + } + } else { + options.push({ + label: field.uiSchema?.title ?? field.name, + value: field.name, + }); + } + }); + return options; +} - const recordPickerViewer = { - type: 'void', - title: t('View record'), - 'x-component': 'RecordPicker.Viewer', - '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('Detail'), - 'x-component': 'Tabs.TabPane', - 'x-designer': 'Tabs.Designer', - 'x-component-props': {}, - properties: { - grid: { - type: 'void', - 'x-component': 'Grid', - 'x-initializer': 'SnapshotBlockInitializers', - properties: {}, - }, +const recordPickerViewer = { + type: 'void', + title: `{{t('View record')}}`, + 'x-component': 'RecordPicker.Viewer', + '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('Detail')}}`, + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'SnapshotBlockInitializers', + properties: {}, }, }, }, }, }, - }; - - const snapshot: IField = { - name: 'snapshot', - type: 'object', - group: 'advanced', - title: t('Snapshot'), - description: t('Snapshot to description'), - default: { - type: 'snapshot', - // name, - uiSchema: { - // title, - 'x-component': 'SnapshotRecordPicker', - 'x-component-props': { - multiple: true, - fieldNames: { - label: 'id', - value: 'id', - }, - }, - }, - }, - schemaInitialize(schema: ISchema, { field, readPretty, action, block }) { - schema['properties'] = { - viewer: cloneDeep(recordPickerViewer), - }; - }, - initialize: (values: any) => {}, - properties: { - ...defaultProps, - [TARGET_FIELD]: { - type: 'string', - title: t('Association field'), - required: true, - 'x-decorator': 'FormItem', - 'x-component': 'SnapshotOwnerCollectionFieldsSelect', - 'x-disabled': '{{ !createOnly || isOverride }}', - 'x-reactions': [ - { - target: APPENDS, - when: '{{$self.value != undefined}}', - fulfill: { - state: { - visible: true, - }, - }, - otherwise: { - state: { - visible: false, - }, - }, - }, - ], - }, - [APPENDS]: { - type: 'string', - title: t('Deep copy fields'), - description: t('When a record is created, relational data is backed up in a snapshot'), - 'x-decorator': 'FormItem', - 'x-component': 'AppendsTreeSelect', - 'x-reactions': [ - { - dependencies: [TARGET_FIELD], - when: '{{$deps[0]}}', - fulfill: { - run: '{{$self.setValue($self.value)}}', - }, - }, - ], - }, - }, - }; - - return useMemo(() => snapshot, [t]); + }, +}; + +export const snapshot: IField = { + name: 'snapshot', + type: 'object', + group: 'advanced', + title: `{{t('Snapshot', {ns: '${NAMESPACE}'})}}`, + description: `{{t('Snapshot to description', {ns: '${NAMESPACE}'})}}`, + default: { + type: 'snapshot', + // name, + uiSchema: { + // title, + 'x-component': 'SnapshotRecordPicker', + 'x-component-props': { + multiple: true, + fieldNames: { + label: 'id', + value: 'id', + }, + }, + }, + }, + schemaInitialize(schema: ISchema, { field, readPretty, action, block }) { + schema['properties'] = { + viewer: cloneDeep(recordPickerViewer), + }; + }, + initialize: (values: any) => {}, + usePathOptions(field) { + const { appends = [], targetCollection } = field; + const { getCollection } = useCollectionManager(); + const { fields } = getCollection(targetCollection); + + const result = makeFieldsPathOptions(fields, appends); + + return [ + { + label: `{{t('Snapshot data', { ns: '${NAMESPACE}' })}}`, + value: 'data', + children: result, + } + ]; + }, + properties: { + ...defaultProps, + [TARGET_FIELD]: { + type: 'string', + title: `{{t('Association field', {ns: '${NAMESPACE}'})}}`, + required: true, + 'x-decorator': 'FormItem', + 'x-component': 'SnapshotOwnerCollectionFieldsSelect', + 'x-disabled': '{{ !createOnly || isOverride }}', + 'x-reactions': [ + { + target: APPENDS, + when: '{{$self.value != undefined}}', + fulfill: { + state: { + visible: true, + }, + }, + otherwise: { + state: { + visible: false, + }, + }, + }, + ], + }, + [APPENDS]: { + type: 'string', + title: `{{t('Deep copy fields', {ns: '${NAMESPACE}'})}}`, + description: `{{t('When a record is created, relational data is backed up in a snapshot', {ns: '${NAMESPACE}'})}}`, + 'x-decorator': 'FormItem', + 'x-component': 'AppendsTreeSelect', + 'x-reactions': [ + { + dependencies: [TARGET_FIELD], + when: '{{$deps[0]}}', + fulfill: { + run: '{{$self.setValue($self.value)}}', + }, + }, + ], + }, + }, }; diff --git a/packages/plugins/snapshot-field/src/client/locale/zh-CN.ts b/packages/plugins/snapshot-field/src/client/locale/zh-CN.ts index 9b7a450406..d50b4d0fa6 100644 --- a/packages/plugins/snapshot-field/src/client/locale/zh-CN.ts +++ b/packages/plugins/snapshot-field/src/client/locale/zh-CN.ts @@ -9,4 +9,5 @@ export default { 'Deep copy fields': '深复制的字段', 'Please select': '请选择', 'When a record is created, association data is backed up in a snapshot': '创建记录时,关系数据会备份到快照里', + 'Snapshot data': '快照数据', }; diff --git a/packages/plugins/snapshot-field/src/server/fields/snapshot-field.ts b/packages/plugins/snapshot-field/src/server/fields/snapshot-field.ts index 2c377e31bc..f9143ea2e9 100644 --- a/packages/plugins/snapshot-field/src/server/fields/snapshot-field.ts +++ b/packages/plugins/snapshot-field/src/server/fields/snapshot-field.ts @@ -31,18 +31,12 @@ export class SnapshotField extends Field { data = data.toJSON(); } - await model.update( - { - [name]: { - collectionName, - data, - }, + await model.update({ + [name]: { + collectionName, + data, }, - { - transaction, - hooks: false, - }, - ); + }, { transaction }); }; bind() {