From 6218a3c7ce82a84a015506c80b4cdda831ec9eb4 Mon Sep 17 00:00:00 2001 From: sheldon66 Date: Sun, 16 Mar 2025 08:26:30 +0800 Subject: [PATCH] feat: init render variables --- .../antd/variable/VariableProvider.tsx | 7 +- .../src/__tests__/ctx-func.test.ts | 7 +- .../src/parser/json-template-parser.ts | 41 +++--- .../src/middlewares/render-variables.ts | 126 ++++++++++++++++++ packages/core/utils/src/render-filter.ts | 22 +++ 5 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 packages/core/server/src/middlewares/render-variables.ts create mode 100644 packages/core/utils/src/render-filter.ts diff --git a/packages/core/client/src/schema-component/antd/variable/VariableProvider.tsx b/packages/core/client/src/schema-component/antd/variable/VariableProvider.tsx index 9daaf168fc..03f1a405ee 100644 --- a/packages/core/client/src/schema-component/antd/variable/VariableProvider.tsx +++ b/packages/core/client/src/schema-component/antd/variable/VariableProvider.tsx @@ -7,15 +7,14 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { useVariables, useLocalVariables } from '../../../variables'; -import { getPath } from '../../../variables/utils/getPath'; import { isArray } from 'lodash'; +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useLocalVariables, useVariables } from '../../../variables'; interface VariableContextValue { value: any; } -interface VariableProviderProps { +interface eProviderProps { variableName: string; children: React.ReactNode; } diff --git a/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts b/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts index a71a99a6d4..2874a86336 100644 --- a/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts +++ b/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts @@ -31,7 +31,7 @@ describe('ctx function', () => { userId: 1, }, }; - const result = await parser.render(template, data); + const result = await parser.render(template, data, data); expect(result).toEqual('1 - 1'); }); @@ -113,11 +113,14 @@ describe('ctx function', () => { return (field) => 1; } else return (field) => 2; }, + }; + + const context = { state: { userId: 1, }, }; - const result = await parser.render(template, data); + const result = await parser.render(template, data, context); expect(result).toEqual(' 1 - 1 '); }); }); 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 3874d87e12..bc599b9a58 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 @@ -9,7 +9,6 @@ import { Liquid, TokenKind } from 'liquidjs'; import { get } from 'lodash'; -import { variableFilters, filterGroups } from '../filters'; import { escape, revertEscape } from '../escape'; type FilterGroup = { name: string; @@ -64,16 +63,20 @@ export class JSONTemplateParser { this._engine.registerFilter(filter.name, filter.handler); } - async render(template: string, data: any = {}): Promise { - const NamespaceMap = new Map; fnWrapper: Function; fn?: any }>(); + async render(template: string, data: any = {}, context?: Record): Promise { + const NamespaceMap = new Map; fnWrapper: Function; fn?: any }>(); Object.keys(data).forEach((key) => { if (key.startsWith('$') || typeof data[key] === 'function') { - NamespaceMap.set(escape(key), { fields: new Set(), fnWrapper: data[key] }); + NamespaceMap.set(escape(key), { fieldSet: new Set(), fnWrapper: data[key] }); } }); const parsed = this.parse(template, { NamespaceMap }); - const fnPromises = Array.from(NamespaceMap.entries()).map(async ([key, { fields, fnWrapper }]) => { - const fn = await fnWrapper({ fields: Array.from(fields), context: data }); + 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); @@ -87,7 +90,7 @@ export class JSONTemplateParser { parse = ( value: any, - opts?: { NamespaceMap: Map; fnWrapper: Function; fn?: Function }> }, + opts?: { NamespaceMap: Map; fnWrapper: Function; fn?: Function }> }, ) => { const engine = this.engine; function type(value) { @@ -114,7 +117,7 @@ export class JSONTemplateParser { // template parameter syntax such as {{foo}} or {{foo:someDefault}}. const getFieldName = ({ variableName, variableSegments }) => revertEscape(variableName.slice(variableSegments[0].length + 1)); - return (str) => { + return (str, preKeys: string[]) => { const escapeStr = escape(str); const rawTemplates = engine.parse(escapeStr); const templates = rawTemplates.map((rawTemplate) => { @@ -135,7 +138,7 @@ export class JSONTemplateParser { variableSegments.length > 1 && opts.NamespaceMap.has(variableSegments[0]) ) { - const fieldSet = opts.NamespaceMap.get(variableSegments[0]).fields; + const fieldSet = opts.NamespaceMap.get(variableSegments[0]).fieldSet; const field = getFieldName({ variableName, variableSegments }); fieldSet.add(field); } @@ -182,7 +185,7 @@ export class JSONTemplateParser { if (!fn) { throw new Error(`fn not found for ${scopeKey}`); } - return fn(revertEscape(field)); + return fn(revertEscape(field), preKeys); } else if (typeof ctxVal === 'function') { const ctxVal = get(escapedContext, template.variableName); value = ctxVal(); @@ -212,10 +215,10 @@ export class JSONTemplateParser { }; })(); - function parseObject(object) { + function parseObject(object, preKeys) { const children = Object.keys(object).map((key) => ({ - keyTemplate: parseString(key), - valueTemplate: _parse(object[key]), + keyTemplate: parseString(key, preKeys), + valueTemplate: _parse(object[key], [...preKeys, key]), })); const templateParameters = children.reduce( (parameters, child) => parameters.concat(child.valueTemplate.parameters, child.keyTemplate.parameters), @@ -232,22 +235,22 @@ export class JSONTemplateParser { } // Parses non-leaf-nodes in the template object that are arrays. - function parseArray(array) { - const templates = array.map(_parse); + function parseArray(array, preKeys) { + const templates = array.map((item, index) => _parse(item, [...preKeys, index])); const templateParameters = templates.reduce((parameters, template) => parameters.concat(template.parameters), []); const templateFn = (context) => templates.map((template) => template(context)); return Template(templateFn, templateParameters); } - function _parse(value) { + function _parse(value, keys = []) { switch (type(value)) { case 'string': - return parseString(value); + return parseString(value, keys); case 'object': - return parseObject(value); + return parseObject(value, keys); case 'array': - return parseArray(value); + return parseArray(value, keys); default: return Template(function () { return value; diff --git a/packages/core/server/src/middlewares/render-variables.ts b/packages/core/server/src/middlewares/render-variables.ts new file mode 100644 index 0000000000..4b57c420e6 --- /dev/null +++ b/packages/core/server/src/middlewares/render-variables.ts @@ -0,0 +1,126 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createJSONTemplateParser } from '@nocobase/json-template-parser'; +import { getDateVars } from '@nocobase/utils'; +import { get } from 'lodash'; + +function getUser(ctx) { + return async ({ fields }) => { + const userFields = fields.filter((f) => f && ctx.db.getFieldByPath('users.' + f)); + ctx.logger?.info('filter-parse: ', { userFields }); + if (!ctx.state.currentUser) { + return; + } + if (!userFields.length) { + return; + } + const user = await ctx.db.getRepository('users').findOne({ + filterByTk: ctx.state.currentUser.id, + fields: userFields, + }); + ctx.logger?.info('filter-parse: ', { + $user: user?.toJSON(), + }); + return ({ field }) => { + get(user, field); + }; + }; +} + +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', + '$dateNotOn', + '$dateBefore', + '$dateAfter', + '$dateNotBefore', + '$dateNotAfter', + '$dateBetween', + ].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; + } + + if (Array.isArray(value)) { + if (value.length === 2) { + value.push('[]', timezone); + } else if (value.length === 3) { + value.push(timezone); + } + return value; + } + + if (typeof value === 'string') { + if (!timezone || /(\+|-)\d\d:\d\d$/.test(value)) { + return value; + } + return value + timezone; + } + + if (isDate(value)) { + return value.toISOString(); + } +}; + +const $date = ({ fields, data, context }) => { + const timezone = context.timezone; + const dateVars = getDateVars(); + return (field, keys) => { + const value = get(dateVars, field); + const operator = keys[keys.length - 1]; + if (isDateOperator(operator)) { + const field = context?.getField?.(keys); + if (field?.constructor.name === 'DateOnlyField' || field?.constructor.name === 'DatetimeNoTzField') { + return value; + } + return dateValueWrapper(value, field?.timezone || timezone); + } + }; +}; + +const parser = createJSONTemplateParser(); + +export async function renderVariables(ctx, next) { + const filter = ctx.action.params.filter; + if (!filter) { + return next(); + } + ctx.action.params.filter = await parser.render( + filter, + { + $user: getUser(ctx), + $date, + $nDate: $date, + $nRole: 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 { resourceName } = ctx.action; + return ctx.db.getFieldByPath(`${resourceName}.${fieldPath}`); + }, + }, + ); + await next(); +} diff --git a/packages/core/utils/src/render-filter.ts b/packages/core/utils/src/render-filter.ts new file mode 100644 index 0000000000..8250e25c71 --- /dev/null +++ b/packages/core/utils/src/render-filter.ts @@ -0,0 +1,22 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { createJSONTemplateParser } from '@nocobase/json-template-parser'; + +const parser = createJSONTemplateParser(); + +type ParseFilterOptions = { + now?: any; + timezone?: string; + getField?: any; +}; + +function renderFilters(filters, data, context: ParseFilterOptions = {}) { + return parser.render(filters, data, context); +}