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
This commit is contained in:
Junyi 2023-02-28 17:55:58 +08:00 committed by GitHub
parent 15d067120d
commit e752686c7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 181 additions and 130 deletions

View File

@ -6,7 +6,7 @@ import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import moment from 'moment'; import moment from 'moment';
import { useCompile } from '../../hooks/useCompile'; import { useCompile } from '../..';
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;

View File

@ -42,7 +42,7 @@ export function JSONInput(props) {
return; return;
} }
const variable = `"{{${selected.join('.')}}}"`; const variable = `{{${selected.join('.')}}}`;
const { textArea } = inputRef.current.resizableTextArea; const { textArea } = inputRef.current.resizableTextArea;
const nextValue = textArea.value.slice(0, textArea.selectionStart) + variable + textArea.value.slice(textArea.selectionEnd); const nextValue = textArea.value.slice(0, textArea.selectionStart) + variable + textArea.value.slice(textArea.selectionEnd);

View File

@ -3,6 +3,7 @@ import { Input, Cascader, Tooltip, Button } from 'antd';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { cx, css } from '@emotion/css'; import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCompile } from '../..';
const VARIABLE_RE = /{{\s*([^{}]+)\s*}}/g; const VARIABLE_RE = /{{\s*([^{}]+)\s*}}/g;
@ -105,9 +106,10 @@ function createVariableTagHTML(variable, keyLabelMap) {
export function TextArea(props) { export function TextArea(props) {
const { value = '', scope, onChange, multiline = true, button } = props; const { value = '', scope, onChange, multiline = true, button } = props;
const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const options = (typeof scope === 'function' ? scope() : scope) ?? []; const options = compile((typeof scope === 'function' ? scope() : scope) ?? []);
const form = useForm(); const form = useForm();
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]);
const [changed, setChanged] = useState(false); const [changed, setChanged] = useState(false);

View File

@ -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; 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 = {}) { export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) {
const context = cloneDeep(scope);
const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => { 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"]'); const key = v.replace(/\.(\d+)/g, '["$1"]');
return ` ${typeof item === 'function' ? item() : key} `; return ` ${typeof item === 'function' ? item() : key} `;
}); });
return this(exp, scope); return this(exp, context);
} }

View File

@ -162,6 +162,7 @@ export default {
'select', 'select',
'multipleSelect', 'multipleSelect',
'snapshot'
// 'json' // 'json'
], ],
useCurrentFields: '{{ useCurrentFields }}', useCurrentFields: '{{ useCurrentFields }}',

View File

@ -1,17 +1,20 @@
import React from 'react'; import React from 'react';
import { useCompile, Variable } from '@nocobase/client'; import { useCollectionManager, useCompile, Variable } from '@nocobase/client';
export const Expression = (props) => { export const Expression = (props) => {
const { value = '', supports, useCurrentFields, onChange } = props; const { value = '', supports = [], useCurrentFields, onChange } = props;
const compile = useCompile(); 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 => ({ const options = fields.map(field => ({
label: compile(field.uiSchema.title), label: compile(field.uiSchema.title),
value: field.name value: field.name,
children: interfaces[field.interface].usePathOptions?.(field)
})); }));
return ( return (

View File

@ -7,7 +7,7 @@ import {
SchemaInitializerProvider, SchemaInitializerProvider,
} from '@nocobase/client'; } from '@nocobase/client';
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { useSnapshotInterface } from './interface'; import { snapshot } from './interface';
import { SnapshotRecordPicker } from './SnapshotRecordPicker'; import { SnapshotRecordPicker } from './SnapshotRecordPicker';
import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers'; import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers';
import { SnapshotBlockInitializersDetailItem } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializersDetailItem'; import { SnapshotBlockInitializersDetailItem } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializersDetailItem';
@ -17,7 +17,6 @@ import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerC
export default React.memo((props) => { export default React.memo((props) => {
const initializers = useContext(SchemaInitializerContext); const initializers = useContext(SchemaInitializerContext);
const snapshot = useSnapshotInterface();
useEffect(() => { useEffect(() => {
registerField(snapshot.group, snapshot.name as string, snapshot); registerField(snapshot.group, snapshot.name as string, snapshot);

View File

@ -1,9 +1,8 @@
import type { Field } from '@formily/core'; import type { Field } from '@formily/core';
import { ISchema } from '@formily/react'; 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 { cloneDeep } from 'lodash';
import { useMemo } from 'react'; import { NAMESPACE } from './locale';
import { useSnapshotTranslation } from './locale';
const { defaultProps } = interfacesProperties; const { defaultProps } = interfacesProperties;
@ -27,114 +26,151 @@ const onTargetFieldChange = (field: Field) => {
!targetField.getState().disabled && targetField.setValue([]); !targetField.getState().disabled && targetField.setValue([]);
}; };
export const useSnapshotInterface = () => { function makeFieldsPathOptions(fields, appends = []) {
const { t } = useSnapshotTranslation(); 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 = { const recordPickerViewer = {
type: 'void', type: 'void',
title: t('View record'), title: `{{t('View record')}}`,
'x-component': 'RecordPicker.Viewer', 'x-component': 'RecordPicker.Viewer',
'x-component-props': { 'x-component-props': {
className: 'nb-action-popup', className: 'nb-action-popup',
}, },
properties: { properties: {
tabs: { tabs: {
type: 'void', type: 'void',
'x-component': 'Tabs', 'x-component': 'Tabs',
'x-component-props': {}, 'x-component-props': {},
// 'x-initializer': 'TabPaneInitializers', // 'x-initializer': 'TabPaneInitializers',
properties: { properties: {
tab1: { tab1: {
type: 'void', type: 'void',
title: t('Detail'), title: `{{t('Detail')}}`,
'x-component': 'Tabs.TabPane', 'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer', 'x-designer': 'Tabs.Designer',
'x-component-props': {}, 'x-component-props': {},
properties: { properties: {
grid: { grid: {
type: 'void', type: 'void',
'x-component': 'Grid', 'x-component': 'Grid',
'x-initializer': 'SnapshotBlockInitializers', 'x-initializer': 'SnapshotBlockInitializers',
properties: {}, properties: {},
},
}, },
}, },
}, },
}, },
}, },
}; },
};
const snapshot: IField = {
name: 'snapshot', export const snapshot: IField = {
type: 'object', name: 'snapshot',
group: 'advanced', type: 'object',
title: t('Snapshot'), group: 'advanced',
description: t('Snapshot to description'), title: `{{t('Snapshot', {ns: '${NAMESPACE}'})}}`,
default: { description: `{{t('Snapshot to description', {ns: '${NAMESPACE}'})}}`,
type: 'snapshot', default: {
// name, type: 'snapshot',
uiSchema: { // name,
// title, uiSchema: {
'x-component': 'SnapshotRecordPicker', // title,
'x-component-props': { 'x-component': 'SnapshotRecordPicker',
multiple: true, 'x-component-props': {
fieldNames: { multiple: true,
label: 'id', fieldNames: {
value: 'id', label: 'id',
}, value: 'id',
}, },
}, },
}, },
schemaInitialize(schema: ISchema, { field, readPretty, action, block }) { },
schema['properties'] = { schemaInitialize(schema: ISchema, { field, readPretty, action, block }) {
viewer: cloneDeep(recordPickerViewer), schema['properties'] = {
}; viewer: cloneDeep(recordPickerViewer),
}, };
initialize: (values: any) => {}, },
properties: { initialize: (values: any) => {},
...defaultProps, usePathOptions(field) {
[TARGET_FIELD]: { const { appends = [], targetCollection } = field;
type: 'string', const { getCollection } = useCollectionManager();
title: t('Association field'), const { fields } = getCollection(targetCollection);
required: true,
'x-decorator': 'FormItem', const result = makeFieldsPathOptions(fields, appends);
'x-component': 'SnapshotOwnerCollectionFieldsSelect',
'x-disabled': '{{ !createOnly || isOverride }}', return [
'x-reactions': [ {
{ label: `{{t('Snapshot data', { ns: '${NAMESPACE}' })}}`,
target: APPENDS, value: 'data',
when: '{{$self.value != undefined}}', children: result,
fulfill: { }
state: { ];
visible: true, },
}, properties: {
}, ...defaultProps,
otherwise: { [TARGET_FIELD]: {
state: { type: 'string',
visible: false, title: `{{t('Association field', {ns: '${NAMESPACE}'})}}`,
}, required: true,
}, 'x-decorator': 'FormItem',
}, 'x-component': 'SnapshotOwnerCollectionFieldsSelect',
], 'x-disabled': '{{ !createOnly || isOverride }}',
}, 'x-reactions': [
[APPENDS]: { {
type: 'string', target: APPENDS,
title: t('Deep copy fields'), when: '{{$self.value != undefined}}',
description: t('When a record is created, relational data is backed up in a snapshot'), fulfill: {
'x-decorator': 'FormItem', state: {
'x-component': 'AppendsTreeSelect', visible: true,
'x-reactions': [ },
{ },
dependencies: [TARGET_FIELD], otherwise: {
when: '{{$deps[0]}}', state: {
fulfill: { visible: false,
run: '{{$self.setValue($self.value)}}', },
}, },
}, },
], ],
}, },
}, [APPENDS]: {
}; type: 'string',
title: `{{t('Deep copy fields', {ns: '${NAMESPACE}'})}}`,
return useMemo<IField>(() => snapshot, [t]); 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)}}',
},
},
],
},
},
}; };

View File

@ -9,4 +9,5 @@ export default {
'Deep copy fields': '深复制的字段', 'Deep copy fields': '深复制的字段',
'Please select': '请选择', 'Please select': '请选择',
'When a record is created, association data is backed up in a snapshot': '创建记录时,关系数据会备份到快照里', 'When a record is created, association data is backed up in a snapshot': '创建记录时,关系数据会备份到快照里',
'Snapshot data': '快照数据',
}; };

View File

@ -31,18 +31,12 @@ export class SnapshotField extends Field {
data = data.toJSON(); data = data.toJSON();
} }
await model.update( await model.update({
{ [name]: {
[name]: { collectionName,
collectionName, data,
data,
},
}, },
{ }, { transaction });
transaction,
hooks: false,
},
);
}; };
bind() { bind() {