feat: add helper components and enhance filter handling in VariablesProvider

This commit is contained in:
Sheldon Guo 2025-03-13 07:06:37 +08:00
parent b51c42c70c
commit 21d45bf59a
11 changed files with 385 additions and 52 deletions

View File

@ -0,0 +1,40 @@
/**
* 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 React, { useState } from 'react';
import { Popover } from 'antd';
import { HelperConfiguator } from './HelperConfiguator';
const WithPropOver = ({ children, index }) => {
const [open, setOpen] = useState(false);
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
return (
<Popover
open={open}
onOpenChange={handleOpenChange}
content={<HelperConfiguator index={index} onDelete={() => setOpen(false)} />}
trigger={'click'}
>
{children}
</Popover>
);
};
export function Helper({ configurable, index, label }: { configurable: boolean; index: number; label: string }) {
const Label = <div style={{ color: '#52c41a', display: 'inline-block', cursor: 'pointer' }}>{label}</div>;
return (
<>
<span style={{ color: '#bfbfbf', margin: '0 5px' }}>|</span>
{configurable ? <WithPropOver index={index}>{Label}</WithPropOver> : Label}
</>
);
}

View File

@ -0,0 +1,47 @@
/**
* 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 React, { useState, useMemo, useContext } from 'react';
import type { MenuProps } from 'antd';
import { Dropdown, Popover } from 'antd';
import { FilterOutlined } from '@ant-design/icons';
import { useCompile } from '../../../hooks';
import { useApp } from '../../../../application';
import { addHelper } from './observables';
export function HelperAddition() {
const app = useApp();
const compile = useCompile();
const filterOptions = app.jsonTemplateParser.filterGroups
.sort((a, b) => a.sort - b.sort)
.map((group) => ({
key: group.name,
label: compile(group.title),
children: group.filters
.sort((a, b) => a.sort - b.sort)
.map((filter) => ({ key: filter.name, label: compile(filter.title) })),
})) as MenuProps['items'];
return (
<>
<span style={{ color: '#bfbfbf', margin: '0 5px' }}>|</span>
<Dropdown
menu={{
items: filterOptions,
onClick: ({ key }) => {
addHelper({ name: key });
},
}}
>
<a onClick={(e) => e.preventDefault()}>
<FilterOutlined style={{ color: '#52c41a' }} />
</a>
</Dropdown>
</>
);
}

View File

@ -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 React, { useState, useMemo, useContext } from 'react';
import { useForm, FormContext, observer } from '@formily/react';
import { createForm, onFormValuesChange, Form } from '@formily/core';
import { uid, tval } from '@nocobase/utils/client';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { SchemaComponent } from '../../../core';
import { helpersObs, rawHelpersObs, removeHelper } from './observables';
import { useVariable } from '../VariableProvider';
import debounce from 'lodash/debounce';
export const HelperConfiguator = observer(
({ index, onDelete }: { index: number; onDelete: () => void }) => {
const helper = helpersObs.value[index];
const rawHelper = rawHelpersObs.value[index];
const config = helper.config;
const history = createMemoryHistory();
const previousHelpers = helpersObs.value.slice(0, index);
const { value } = useVariable();
const inputValue = previousHelpers.reduce((value, helper) => {
return helper.handler(value, ...helper.args);
}, value);
const outputValue = helpersObs.value.slice(0, index + 1).reduce((value, helper) => {
return helper.handler(value, ...helper.args);
}, value);
const useFormBlockProps = () => {
return {
form: createForm({
initialValues: helper.argsMap,
effects() {
onFormValuesChange(
debounce((form) => {
rawHelper.argsMap = form.values;
}, 500),
);
},
}),
layout: 'vertical',
};
};
const useDeleteActionProps = () => {
const form = useForm();
return {
type: 'primary',
danger: true,
onClick: async () => {
removeHelper({ index: index });
onDelete();
},
};
};
const schema = {
'x-uid': uid(),
type: 'void',
'x-component': 'CardItem',
properties: {
form: {
'x-uid': uid(),
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': 'useFormBlockProps',
properties: {
'$input-value': {
type: 'void',
'x-component': 'div',
'x-content': '{{ inputValue }}',
'x-decorator': 'FormItem',
title: tval('Input Value'),
},
...Object.fromEntries(
config.uiSchema.map((param) => [
param.name,
{
...param,
'x-decorator': 'FormItem',
},
]),
),
'$return-value': {
type: 'void',
'x-component': 'div',
'x-content': '{{ outputValue }}',
'x-decorator': 'FormItem',
title: tval('Return Value'),
},
actions: {
type: 'void',
title: tval('Save'),
'x-component': 'ActionBar',
properties: {
delete: {
type: 'void',
title: tval('Delete'),
'x-component': 'Action',
'x-use-component-props': 'useDeleteActionProps',
},
},
},
},
},
},
};
return (
<Router location={history.location} navigator={history}>
<SchemaComponent
schema={schema}
scope={{ useFormBlockProps, useDeleteActionProps, outputValue, inputValue }}
basePath={['']}
/>
</Router>
);
},
{ displayName: 'Helper' },
);

View File

@ -0,0 +1,37 @@
/**
* 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 React, { useState, useMemo, useContext } from 'react';
import { observer } from '@formily/reactive-react';
import type { MenuProps } from 'antd';
import { Dropdown, Popover } from 'antd';
import { FilterOutlined } from '@ant-design/icons';
import { useCompile } from '../../../hooks';
import { useApp } from '../../../../application';
import { addHelper } from './observables';
import { helpersObs } from './observables';
import { Helper } from './Helper';
const _HelperList = () => {
return (
<>
{helpersObs.value.map((helper, index) => {
return (
<Helper
key={index}
index={index}
configurable={Boolean(helper.config.uiSchema)}
label={helper.config.title}
/>
);
})}
</>
);
};
export const HelperList = observer(_HelperList, { displayName: 'HelperList' });

View File

@ -0,0 +1,11 @@
/**
* 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.
*/
export { HelperList } from './HelperList';
export { HelperAddition } from './HelperAddition';

View File

@ -0,0 +1,72 @@
/**
* 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 { observable } from '@formily/reactive';
import { extractTemplateElements, createJSONTemplateParser } from '@nocobase/json-template-parser';
type Helper = {
name: string;
argsMap: Record<string, any>;
config: any;
args: string[];
handler: (value: any, ...args: any[]) => any;
};
type RawHelper = {
name: string;
argsMap: Record<string, any>;
};
const parser = createJSONTemplateParser();
export const rawHelpersObs = observable<{ value: RawHelper[] }>({ value: [] });
export const allHelpersConfigObs = observable<{ value: any[] }>({ value: parser.filters });
export const helpersObs = observable.computed(() => {
return rawHelpersObs.value.map((helper) => {
const config = allHelpersConfigObs.value.find((f) => f.name === helper.name);
const args = config.uiSchema.map((param) => helper.argsMap[param.name]);
return {
...helper,
config,
args,
handler: config.handler,
};
});
}) as { value: Helper[] };
export const variableNameObs = observable<{ value: string }>({ value: '' });
export const variableSegmentsObs = observable<{ value: string[] }>({ value: [] });
export const addHelper = ({ name }: { name: string }) => {
rawHelpersObs.value.push({ name, argsMap: {} });
};
export const removeHelper = ({ index }: { index: number }) => {
rawHelpersObs.value.splice(index, 1);
};
export const setHelpersFromTemplateStr = ({ template }: { template: string }) => {
const { fullVariable, helpers, variableSegments } = extractTemplateElements(
typeof template === 'string' ? template : '',
);
variableNameObs.value = fullVariable;
variableSegmentsObs.value = variableSegments;
const computedHelpers = helpers.map((helper: any) => {
const config = allHelpersConfigObs.value.find((f) => f.name === helper.name);
const argsMap = config.uiSchema
? Object.fromEntries(config.uiSchema.map((param, index) => [param.name, helper.args[index]]))
: {};
return {
name: helper.name,
argsMap,
};
});
rawHelpersObs.value = computedHelpers;
};

View File

@ -9,7 +9,8 @@
import { CloseCircleFilled, FilterOutlined } from '@ant-design/icons';
import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react';
import { autorun } from '@formily/reactive';
import { useForm, observer } from '@formily/react';
import { error } from '@nocobase/utils/client';
import { cloneDeep } from 'lodash';
import { extractTemplateElements, composeTemplate } from '@nocobase/json-template-parser';
@ -34,8 +35,9 @@ import { useCompile } from '../../hooks';
import { XButton } from './XButton';
import { useStyles } from './style';
import { Json } from '../input';
import { Filters, Addition, FilterContext } from './VariableFilters';
import { VariableProvider } from './VariableProvider';
import { setHelpersFromTemplateStr, helpersObs } from './Helpers/observables';
import { HelperList, HelperAddition } from './Helpers';
const { Text } = Typography;
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
@ -230,33 +232,20 @@ export function Input(props: VariableInputProps) {
hideVariableButton || (children && value != null ? true : false),
);
const { fullVariable, filters, variableSegments } = useMemo(
useEffect(() => {
setHelpersFromTemplateStr({ template: typeof value === 'string' ? value : '' });
}, [value]);
const { fullVariable, helpers, variableSegments } = useMemo(
() => extractTemplateElements(typeof value === 'string' ? value : ''),
[value],
);
const onFilterAdd = useCallback(
(filterName) => {
onChange(composeTemplate({ fullVariable, filters: [...filters, { name: filterName, args: [] }] }));
},
[filters, fullVariable, onChange],
);
const updateFilterParams = useCallback(
({ filterId, params }: { filterId: number; params: any[] }) => {
const copyedFilters = cloneDeep(filters);
copyedFilters[filterId].args = params;
onChange(composeTemplate({ fullVariable, filters: copyedFilters }));
},
[filters, fullVariable, onChange],
);
const deleteFilter = useCallback(
({ filterId }: { filterId: number }) => {
const newFilters = filters.filter((_, index) => index !== filterId);
onChange(composeTemplate({ fullVariable, filters: newFilters }));
},
[filters, fullVariable, onChange],
);
useEffect(() => {
const dispose = autorun(() => {
onChange(composeTemplate({ fullVariable, helpers: helpersObs.value }));
});
return dispose;
}, [onChange, fullVariable, helpers]);
const parsed = useMemo(() => parseValue(variableSegments, parseOptions), [parseOptions, variableSegments]);
const isConstant = typeof parsed === 'string';
@ -374,7 +363,7 @@ export function Input(props: VariableInputProps) {
if (next[1] !== type) {
// setPrevType(next[1]);
const newVariable = ConstantTypes[next[1]]?.default?.() ?? null;
onChange(composeTemplate({ fullVariable: newVariable, filters }), optionPath);
onChange(composeTemplate({ fullVariable: newVariable, helpers }), optionPath);
}
} else {
if (variable) {
@ -486,11 +475,8 @@ export function Input(props: VariableInputProps) {
);
})}
<VariableProvider variableName={fullVariable}>
<FilterContext.Provider value={{ updateFilterParams, deleteFilter, variableName: fullVariable }}>
<Filters filters={filters} onFilterChange={onFilterAdd} />
{variableText.length > 0 && <Addition variable={fullVariable} onFilterAdd={onFilterAdd} />}
</FilterContext.Provider>
<HelperList />
{variableText.length > 0 && <HelperAddition />}
</VariableProvider>
</Tag>
</div>
@ -546,3 +532,5 @@ export function Input(props: VariableInputProps) {
</Space.Compact>,
);
}
// export const Input = observer(_Input, { displayName: 'VariableInput' });

View File

@ -288,7 +288,7 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
const path = getPath(str);
const result = await getResult(path, localVariables as VariableOption[], options);
if (filters.length > 0) {
if (Array.isArray(filters) && filters.length > 0) {
result.value = filters.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), result.value);
}

View File

@ -12,6 +12,11 @@ import { createJSONTemplateParser } from '../parser';
const parser = createJSONTemplateParser();
describe('ctx function', () => {
it('should handle basic context function', () => {
const template = '{{}}';
const variable = parser.engine.fullVariablesSync(template);
console.log(variable);
});
it('should handle basic context function with state', () => {
const template = '{{$user.id}} - {{$user.name}}';
const data = {

View File

@ -18,27 +18,34 @@ type Filter = {
args: string[];
};
export function extractTemplateVariable(template: string) {
export function extractTemplateVariable(template: string): string | null {
const escapedTemplate = escape(template ?? '');
const fullVariable = engine.fullVariablesSync(escapedTemplate)[0] ?? '';
return revertEscape(fullVariable);
try {
const fullVariable = engine.fullVariablesSync(escapedTemplate)[0] ?? '';
return revertEscape(fullVariable);
} catch (e) {
return null;
}
}
export function extractTemplateElements(template: string) {
const escapedTemplate = escape(template ?? '');
const fullVariable = engine.fullVariablesSync(escapedTemplate)[0] ?? '';
const variableSegments = engine.variableSegmentsSync(escapedTemplate)[0] ?? [];
const parsedTemplate = engine.parse(escapedTemplate)[0] ?? {};
try {
const fullVariable = engine.fullVariablesSync(escapedTemplate)[0] ?? '';
const variableSegments = engine.variableSegmentsSync(escapedTemplate)[0] ?? [];
const parsedTemplate = engine.parse(escapedTemplate)[0] ?? {};
const helpers =
//@ts-ignore
parsedTemplate?.value?.filters?.map(({ name, handler, args }) => ({
name,
handler,
args: args.map((arg) => arg.content),
})) ?? [];
const filters =
//@ts-ignore
parsedTemplate?.value?.filters?.map(({ name, handler, args }) => ({
name,
handler,
args: args.map((arg) => arg.content),
})) ?? [];
return revertEscape({ fullVariable, variableSegments, filters });
return revertEscape({ fullVariable, variableSegments, helpers });
} catch (e) {
return { fullVariable: null, variableSegments: [], helpers: [] };
}
}
const composeFilterTemplate = (filter: Filter) => {
@ -48,8 +55,8 @@ const composeFilterTemplate = (filter: Filter) => {
return value;
};
export const composeTemplate = ({ fullVariable, filters }: { fullVariable: string; filters: any[] }) => {
const filtersTemplate = filters.map(composeFilterTemplate).join(' | ');
export const composeTemplate = ({ fullVariable, helpers }: { fullVariable: string; helpers: any[] }) => {
const filtersTemplate = helpers.map(composeFilterTemplate).join(' | ');
const innerTemplateStr = [fullVariable, filtersTemplate].filter(Boolean).join(' | ');
return `{{${innerTemplateStr}}}`;
};

View File

@ -176,14 +176,14 @@ export const parseFilter = async (filter: any, opts: ParseFilterOptions = {}) =>
if (typeof value === 'string') {
const match = re.exec(value);
if (match) {
const { fullVariable: key, filters } = extractTemplateElements(value);
const { fullVariable: key, filters: helpers } = extractTemplateElements(value);
const val = getValuesByPath(vars, key, null);
const field = getField?.(path);
if (key.startsWith('$date') || key.startsWith('$nDate')) {
const filteredNow = filters.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), now);
const filteredNow = helpers.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), now);
value = typeof val === 'function' ? val?.({ field, operator, timezone, now: filteredNow }) : val;
} else {
value = filters.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), value);
value = helpers.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), value);
}
return value;
}