From 89bb4c434a94c02b78d407b628b3b6426ba17a60 Mon Sep 17 00:00:00 2001 From: Sheldon Guo Date: Tue, 18 Mar 2025 02:57:53 +0800 Subject: [PATCH] feat: fix render variables --- .../schema-component/antd/variable/Input.tsx | 17 +++-- .../src/parser/json-template-parser.ts | 68 +++++++++++++------ packages/core/server/src/application.ts | 8 +-- packages/core/server/src/middlewares/index.ts | 1 + .../src/middlewares/render-variables.ts | 41 +++++------ packages/core/utils/src/date.ts | 4 ++ packages/core/utils/src/parse-filter.ts | 8 +-- .../src/json-template-filters/date.ts | 6 +- 8 files changed, 96 insertions(+), 57 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 265c15a48d..94fe3f91a8 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -10,6 +10,7 @@ import { CloseCircleFilled } from '@ant-design/icons'; import { css, cx } from '@emotion/css'; import { observer, useForm } from '@formily/react'; +import { reaction } from '@formily/reactive'; import { composeTemplate, extractTemplateElements } from '@nocobase/json-template-parser'; import { error } from '@nocobase/utils/client'; import { @@ -239,16 +240,22 @@ function _Input(props: VariableInputProps) { ); const newVal = useMemo(() => composeTemplate({ fullVariable, helpers: helpersObs.value }), [fullVariable]); useEffect(() => { - if (value && newVal !== value) { - onChange(newVal); - } - }, [newVal, value, onChange]); + const dispose = reaction( + () => { + return composeTemplate({ fullVariable, helpers: helpersObs.value }); + }, + (newVal) => { + onChange(newVal); + }, + ); + return dispose; + }, [fullVariable, onChange]); const parsed = useMemo(() => parseValue(variableSegments, parseOptions), [parseOptions, variableSegments]); const isConstant = typeof parsed === 'string'; const type = isConstant ? parsed : ''; const variable = isConstant ? null : parsed; - // const [prevType, setPrevType] = React.useState(type); + const names = Object.assign( { label: 'label', diff --git a/packages/core/json-template-parser/src/parser/json-template-parser.ts b/packages/core/json-template-parser/src/parser/json-template-parser.ts index bc599b9a58..8af994d9ee 100644 --- a/packages/core/json-template-parser/src/parser/json-template-parser.ts +++ b/packages/core/json-template-parser/src/parser/json-template-parser.ts @@ -25,6 +25,18 @@ type Filter = { sort: number; }; +type ScopeFnWrapperResult = { + getValue: (params: { field: string[]; keys: string[] }) => any; + afterApplyHelpers: (params: { field: string[]; keys: string[]; value: any }) => any; +}; + +type ScopeFnWrapper = (params: { + fields: string[]; + data: any; + context?: Record; +}) => Promise; + +type ScopeMapValue = { fieldSet: Set; scopeFnWrapper: ScopeFnWrapper; scopeFn?: ScopeFnWrapperResult }; export class JSONTemplateParser { private _engine: Liquid; private _filterGroups: Array; @@ -64,34 +76,38 @@ export class JSONTemplateParser { } async render(template: string, data: any = {}, context?: Record): Promise { - const NamespaceMap = new Map; fnWrapper: Function; fn?: any }>(); + const NamespaceMap = new Map(); Object.keys(data).forEach((key) => { if (key.startsWith('$') || typeof data[key] === 'function') { - NamespaceMap.set(escape(key), { fieldSet: new Set(), fnWrapper: data[key] }); + NamespaceMap.set(escape(key), { fieldSet: new Set(), scopeFnWrapper: data[key] }); } }); const parsed = this.parse(template, { NamespaceMap }); - const fnPromises = Array.from(NamespaceMap.entries()).map(async ([key, { fieldSet, fnWrapper }]) => { - const fields = Array.from(fieldSet); - if (fields.length === 0) { - return { key, fn: null }; - } - const fn = await fnWrapper({ fields: Array.from(fields), data, context }); - return { key, fn }; - }); - const fns = await Promise.all(fnPromises); - fns.forEach(({ key, fn }) => { + const fnPromises = Array.from(NamespaceMap.entries()).map( + async ([key, { fieldSet, scopeFnWrapper: fnWrapper }]): Promise<{ + key: string; + scopeFn: ScopeFnWrapperResult; + } | null> => { + const fields = Array.from(fieldSet); + if (fields.length === 0) { + return null; + } + const hooks: Record<'afterApplyHelpers', any> = { afterApplyHelpers: null }; + + const scopeFn = await fnWrapper({ fields, data, context }); + return { key, scopeFn }; + }, + ); + const scopeFns = (await Promise.all(fnPromises)).filter(Boolean); + scopeFns.forEach(({ key, scopeFn }) => { const NS = NamespaceMap.get(key); - NS.fn = fn; + NS.scopeFn = scopeFn; }); return parsed(data); } - parse = ( - value: any, - opts?: { NamespaceMap: Map; fnWrapper: Function; fn?: Function }> }, - ) => { + parse = (value: any, opts?: { NamespaceMap: Map }) => { const engine = this.engine; function type(value) { let valueType: string = typeof value; @@ -177,22 +193,32 @@ export class JSONTemplateParser { if (opts?.NamespaceMap && opts.NamespaceMap.has(template.variableSegments[0])) { const scopeKey = template.variableSegments[0]; const NS = opts.NamespaceMap.get(scopeKey); - const fn = NS.fn; + const scopeFn = NS.scopeFn; const field = getFieldName({ variableName: template.variableName, variableSegments: template.variableSegments, }); - if (!fn) { + if (!scopeFn?.getValue) { throw new Error(`fn not found for ${scopeKey}`); } - return fn(revertEscape(field), preKeys); + value = scopeFn.getValue({ field: revertEscape(field), keys: preKeys }); + + const appliedHelpersValue = template.filters.reduce( + (acc, filter) => filter.handler(...[acc, ...filter.args]), + typeof value === 'function' ? value() : value, + ); + + if (scopeFn?.afterApplyHelpers) { + return scopeFn.afterApplyHelpers({ field: preKeys, keys: preKeys, value: appliedHelpersValue }); + } + return appliedHelpersValue; } else if (typeof ctxVal === 'function') { const ctxVal = get(escapedContext, template.variableName); value = ctxVal(); } else { value = get(escapedContext, template.variableName); } - template.filters.reduce( + return template.filters.reduce( (acc, filter) => filter.handler(...[acc, ...filter.args]), typeof value === 'function' ? value() : value, ); diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 89e725831d..2155b949aa 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -25,6 +25,7 @@ import { import { ResourceOptions, Resourcer } from '@nocobase/resourcer'; import { Telemetry, TelemetryOptions } from '@nocobase/telemetry'; +import { createJSONTemplateParser, JSONTemplateParser } from '@nocobase/json-template-parser'; import { LockManager, LockManagerOptions } from '@nocobase/lock-manager'; import { applyMixins, @@ -34,7 +35,6 @@ import { ToposortOptions, wrapMiddlewareWithLogging, } from '@nocobase/utils'; -import { createJSONTemplateParser, JSONTemplateParser } from '@nocobase/json-template-parser'; import { Command, CommandOptions, ParseOptions } from 'commander'; import { randomUUID } from 'crypto'; import glob from 'glob'; @@ -64,7 +64,7 @@ import { import { ApplicationVersion } from './helpers/application-version'; import { Locale } from './locale'; import { MainDataSource } from './main-data-source'; -import { parseVariables } from './middlewares'; +import { renderVariables } from './middlewares'; import { dataTemplate } from './middlewares/data-template'; import validateFilterParams from './middlewares/validate-filter-params'; import { Plugin } from './plugin'; @@ -1260,8 +1260,8 @@ export class Application exten this._dataSourceManager.use(this._authManager.middleware(), { tag: 'auth' }); this._dataSourceManager.use(validateFilterParams, { tag: 'validate-filter-params', before: ['auth'] }); - this._dataSourceManager.use(parseVariables, { - group: 'parseVariables', + this._dataSourceManager.use(renderVariables, { + group: 'renderVariables', after: 'acl', }); diff --git a/packages/core/server/src/middlewares/index.ts b/packages/core/server/src/middlewares/index.ts index 535995a080..4751923b1b 100644 --- a/packages/core/server/src/middlewares/index.ts +++ b/packages/core/server/src/middlewares/index.ts @@ -10,3 +10,4 @@ export * from './data-wrapping'; export * from './extract-client-ip'; export { parseVariables } from './parse-variables'; +export { renderVariables } from './render-variables'; diff --git a/packages/core/server/src/middlewares/render-variables.ts b/packages/core/server/src/middlewares/render-variables.ts index 4b57c420e6..33121bc7c2 100644 --- a/packages/core/server/src/middlewares/render-variables.ts +++ b/packages/core/server/src/middlewares/render-variables.ts @@ -8,11 +8,11 @@ */ import { createJSONTemplateParser } from '@nocobase/json-template-parser'; -import { getDateVars } from '@nocobase/utils'; +import { getDateVars, isDate } from '@nocobase/utils'; import { get } from 'lodash'; function getUser(ctx) { - return async ({ fields }) => { + const getValue = async ({ fields }) => { const userFields = fields.filter((f) => f && ctx.db.getFieldByPath('users.' + f)); ctx.logger?.info('filter-parse: ', { userFields }); if (!ctx.state.currentUser) { @@ -32,13 +32,9 @@ function getUser(ctx) { get(user, field); }; }; + return { getValue }; } -function isNumeric(str: any) { - if (typeof str === 'number') return true; - if (typeof str != 'string') return false; - return !isNaN(str as any) && !isNaN(parseFloat(str)); -} const isDateOperator = (op) => { return [ '$dateOn', @@ -51,10 +47,6 @@ const isDateOperator = (op) => { ].includes(op); }; -function isDate(input) { - return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]'; -} - const dateValueWrapper = (value: any, timezone?: string) => { if (!value) { return null; @@ -70,7 +62,7 @@ const dateValueWrapper = (value: any, timezone?: string) => { } if (typeof value === 'string') { - if (!timezone || /(\+|-)\d\d:\d\d$/.test(value)) { + if (!timezone || /((\+|-)\d\d:\d\d|Z)$/.test(value)) { return value; } return value + timezone; @@ -84,17 +76,24 @@ const dateValueWrapper = (value: any, timezone?: string) => { const $date = ({ fields, data, context }) => { const timezone = context.timezone; const dateVars = getDateVars(); - return (field, keys) => { + + const getValue = ({ field, keys }) => { const value = get(dateVars, field); + return value; + }; + const afterApplyHelpers = ({ field, value, keys }) => { const operator = keys[keys.length - 1]; if (isDateOperator(operator)) { - const field = context?.getField?.(keys); - if (field?.constructor.name === 'DateOnlyField' || field?.constructor.name === 'DatetimeNoTzField') { + const ctxField = context?.getField?.(keys); + if (ctxField?.constructor.name === 'DateOnlyField' || ctxField?.constructor.name === 'DatetimeNoTzField') { return value; } - return dateValueWrapper(value, field?.timezone || timezone); + const result = dateValueWrapper(value, ctxField?.timezone || timezone); + return result; } + return value; }; + return { getValue, afterApplyHelpers }; }; const parser = createJSONTemplateParser(); @@ -104,23 +103,25 @@ export async function renderVariables(ctx, next) { if (!filter) { return next(); } - ctx.action.params.filter = await parser.render( + const renderedFilter = await parser.render( filter, { $user: getUser(ctx), $date, $nDate: $date, - $nRole: ctx.state.currentRole, + $nRole: ctx.state.currentRole === '__union__' ? ctx.state.currentRoles : ctx.state.currentRole, }, { timezone: ctx.get('x-timezone'), now: new Date().toISOString(), getField: (keys) => { - const fieldPath = keys.filter((p) => !p.startsWith('$') && !isNumeric(p)).join('.'); + const fieldPath = keys.filter((p) => typeof p === 'string' && !p.startsWith('$')).join('.'); const { resourceName } = ctx.action; - return ctx.db.getFieldByPath(`${resourceName}.${fieldPath}`); + const field = ctx.db.getFieldByPath(`${resourceName}.${fieldPath}`); + return field; }, }, ); + ctx.action.params.filter = renderedFilter; await next(); } diff --git a/packages/core/utils/src/date.ts b/packages/core/utils/src/date.ts index 2153660a72..d1cc4a5172 100644 --- a/packages/core/utils/src/date.ts +++ b/packages/core/utils/src/date.ts @@ -235,3 +235,7 @@ export const getDateTimeFormat = (picker, format, showTime, timeFormat) => { } return format; }; + +export function isDate(input) { + return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]' || dayjs.isDayjs(input); +} diff --git a/packages/core/utils/src/parse-filter.ts b/packages/core/utils/src/parse-filter.ts index 402f254c33..155a0cfad6 100644 --- a/packages/core/utils/src/parse-filter.ts +++ b/packages/core/utils/src/parse-filter.ts @@ -7,11 +7,11 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { extractTemplateElements } from '@nocobase/json-template-parser'; import _ from 'lodash'; import set from 'lodash/set'; import moment from 'moment'; -import { extractTemplateElements } from '@nocobase/json-template-parser'; -import { offsetFromString } from './date'; +import { isDate, offsetFromString } from './date'; import { dayjs } from './dayjs'; import { getValuesByPath } from './getValuesByPath'; @@ -100,10 +100,6 @@ const isDateOperator = (op) => { ].includes(op); }; -function isDate(input) { - return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]'; -} - const dateValueWrapper = (value: any, timezone?: string) => { if (!value) { return null; diff --git a/packages/plugins/@nocobase/plugin-variable-filters/src/json-template-filters/date.ts b/packages/plugins/@nocobase/plugin-variable-filters/src/json-template-filters/date.ts index 51fc910454..615b0b4d83 100644 --- a/packages/plugins/@nocobase/plugin-variable-filters/src/json-template-filters/date.ts +++ b/packages/plugins/@nocobase/plugin-variable-filters/src/json-template-filters/date.ts @@ -63,8 +63,12 @@ export function dateSubtract(initialValue: any, number: number, unit: any) { return dayjs.isDayjs(value) ? value.subtract(number, unit) : dayjs(value).subtract(number, unit); }; if (Array.isArray(initialValue)) { - return initialValue.map(handler); + const results = initialValue.map(handler); + console.log(results[0].toISOString()); + return results; } else { + const result = handler(initialValue); + console.log(result.toISOString()); return handler(initialValue); } }