Merge branch 'next' into develop

This commit is contained in:
xilesun
2026-03-29 15:37:26 +08:00
16 changed files with 593 additions and 490 deletions
+2 -2
View File
@@ -7,12 +7,12 @@ export NOCOBASE_RUNNING_IN_DOCKER=true
if [ -f /opt/libreoffice24.8.zip ] && [ ! -d /opt/libreoffice24.8 ]; then
echo "Unzipping /opt/libreoffice24.8.zip..."
unzip /opt/libreoffice24.8.zip -d /opt/
unzip -q /opt/libreoffice24.8.zip -d /opt/
fi
if [ -f /opt/instantclient_19_25.zip ] && [ ! -d /opt/instantclient_19_25 ]; then
echo "Unzipping /opt/instantclient_19_25.zip..."
unzip /opt/instantclient_19_25.zip -d /opt/
unzip -q /opt/instantclient_19_25.zip -d /opt/
echo "/opt/instantclient_19_25" > /etc/ld.so.conf.d/oracle-instantclient.conf
ldconfig
fi
@@ -42,6 +42,7 @@
"@nocobase/database": "2.x",
"@nocobase/plugin-ai": "2.x",
"@nocobase/plugin-data-source-main": "2.x",
"@nocobase/plugin-flow-engine": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x",
"@nocobase/utils": "2.x"
@@ -1,134 +0,0 @@
/**
* 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 from 'react';
import type { CSSProperties } from 'react';
import { FilterGroup, VariableFilterItem } from '@nocobase/client';
import type { VariableFilterItemValue } from '@nocobase/client';
import type { FlowModel } from '@nocobase/flow-engine';
import { useFlowSettingsContext, createCollectionContextMeta } from '@nocobase/flow-engine';
import { observable, reaction, toJS } from '@formily/reactive';
import isEqual from 'lodash/isEqual';
import { DeleteOutlined } from '@ant-design/icons';
type LogicOp = '$and' | '$or';
export type FilterCondition = VariableFilterItemValue;
export type FilterGroupValue = {
logic: LogicOp;
items: Array<FilterCondition | FilterGroupValue>;
};
export interface AntdFilterSelectorProps {
// antd Form 注入的受控属性
value?: FilterGroupValue;
onChange?: (next: FilterGroupValue) => void;
// VariableFilterItem 渲染所需
model: FlowModel;
// 是否变量作为右值
rightAsVariable?: boolean;
collectionPath?: string[];
className?: string;
style?: CSSProperties;
}
function ensureFilterShape(v?: Partial<FilterGroupValue> | null): FilterGroupValue {
const logic: LogicOp = v?.logic === '$or' ? '$or' : '$and';
const items = Array.isArray(v?.items) ? v!.items : [];
return { logic, items } as FilterGroupValue;
}
/**
* AntdFilterSelector
* - antd Form.Item 子组件
* - 内部用响应式对象驱动 FilterGroup/VariableFilterItem
* - reaction 桥接所有深层变更为 antd 的 onChange
*/
export const AntdFilterSelector: React.FC<AntdFilterSelectorProps> = ({
value,
onChange,
model,
rightAsVariable = true,
className,
style,
collectionPath,
}) => {
const ctx = useFlowSettingsContext<any>();
React.useEffect(() => {
const [dataSourceKey, collectionName] = collectionPath || [];
if (dataSourceKey && collectionName) {
const collection = model?.context?.dataSourceManager?.getCollection(dataSourceKey, collectionName);
if (collection) {
model.context.defineProperty('collection', {
get: () => collection,
meta: createCollectionContextMeta(() => collection, ctx.t('Current collection')),
});
}
}
}, [ctx, model, collectionPath]);
// 初始化内部响应式值(ref 持有,避免 setState 导致重渲染)
const initial = React.useMemo(() => ensureFilterShape(value), [value]);
const internalRef = React.useRef(observable(initial));
// 记住最新的外部值,给 reaction 中的等价判断使用
const latestValueRef = React.useRef<FilterGroupValue | undefined>(value);
React.useEffect(() => {
latestValueRef.current = value;
}, [value]);
// 外部 value 变化时,就地更新内部响应式对象,避免重建 observable
React.useEffect(() => {
const next = ensureFilterShape(value);
const current = toJS(internalRef.current);
if (!isEqual(current, next)) {
internalRef.current.logic = next.logic;
// 注意:items 是数组,直接替换引用即可让 FilterGroup 响应变化
internalRef.current.items = next.items as any;
}
}, [value]);
// 订阅内部响应式对象变化,上报给 antd 的 onChange(仅一次注册)
React.useEffect(() => {
const dispose = reaction(
() => toJS(internalRef.current),
(snapshot) => {
const outer = ensureFilterShape(latestValueRef.current);
if (!isEqual(snapshot, outer)) {
onChange?.(snapshot);
}
},
);
return () => dispose();
}, [onChange]);
// 缓存 FilterItem 渲染函数,避免每次渲染产生新函数导致子树重绘
const renderFilterItem = React.useCallback(
(p: { value: VariableFilterItemValue }) => (
<VariableFilterItem {...p} model={model} rightAsVariable={rightAsVariable} />
),
[model, rightAsVariable],
);
return (
<div className={className} style={style}>
<FilterGroup value={internalRef.current} FilterItem={renderFilterItem} closeIcon={<DeleteOutlined />} />
</div>
);
};
AntdFilterSelector.displayName = 'AntdFilterSelector';
export default AntdFilterSelector;
@@ -8,7 +8,12 @@
*/
import { ChildPageModel, DataBlockModel, DEFAULT_DATA_SOURCE_KEY } from '@nocobase/client';
import { createCollectionContextMeta, SQLResource, useFlowContext } from '@nocobase/flow-engine';
import {
collectContextParamsForTemplate,
createCollectionContextMeta,
SQLResource,
useFlowContext,
} from '@nocobase/flow-engine';
import React, { createRef } from 'react';
import _ from 'lodash';
import { Button } from 'antd';
@@ -96,6 +101,20 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
return this.getStepParams('chartSettings', 'configure');
}
async buildQueryRequest(query: any) {
if (!query || query?.mode === 'sql') {
return query;
}
const contextParams = await collectContextParamsForTemplate(this.context, query);
if (!contextParams) {
return query;
}
return {
...query,
contextParams,
};
}
async onInit(options) {
super.onInit(options);
this.context.defineProperty('chartRef', {
@@ -128,7 +147,7 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
const initParams = this.getResourceSettingsInitParams();
const initQuery = initParams?.query;
if (initQuery) {
this.applyQuery(initQuery);
this.applyQuery(await this.buildQueryRequest(initQuery));
await this.resource.refresh();
}
} catch (e) {
@@ -286,8 +305,8 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
this.setStepParams('chartSettings', 'configure', values);
if (needQueryData) {
this.applyQuery(values.query);
const isSQL = values?.query?.mode === 'sql';
this.applyQuery(await this.buildQueryRequest(values.query));
// 预览场景:SQL 模式开启 debug(调用 run
if (isSQL) {
(this.resource as SQLResource).setDebug(true);
@@ -432,7 +451,7 @@ ChartBlockModel.registerFlow({
useRawParams: true, // 不默认解析配置里的变量
async handler(ctx, params) {
debugLog('---setting flow handler', params);
let { query } = params;
const { query } = params;
const { chart } = params;
if (!query || !chart) {
return;
@@ -440,11 +459,7 @@ ChartBlockModel.registerFlow({
try {
// 数据部分
if (query.mode !== 'sql') {
// builder 模式下变量解析;sql 模式下交给 sqlResource 处理解析
query = await ctx.resolveJsonTemplate(query);
}
ctx.model.applyQuery(query);
ctx.model.applyQuery(await ctx.model.buildQueryRequest(query));
// 图表部分
await ctx.model.applyChartOptions({
@@ -14,6 +14,24 @@ import type { ChartTypeKey } from './ChartOptionsBuilder.service';
import { sleep, appendColon } from '../utils';
import { useFlowSettingsContext } from '@nocobase/flow-engine';
const renderLabel = (label: string, lang?: string) => {
return (
<div
style={{
width: '100%',
whiteSpace: 'normal',
wordBreak: 'break-word',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
fontWeight: 500,
}}
>
<span>{appendColon(label, lang)}</span>
</div>
);
};
type FormItemSpec =
| {
kind: 'select';
@@ -37,9 +55,10 @@ type FormItemSpec =
export const ChartOptionsBuilder: React.FC<{
columns?: string[];
fieldOptions?: { label: string; value: string }[];
initialValues: any;
onChange: (next: any) => void;
}> = ({ columns, initialValues, onChange }) => {
}> = ({ columns, fieldOptions: fieldOptionsProp, initialValues, onChange }) => {
const t = useT();
const [form] = Form.useForm();
const ctx = useFlowSettingsContext<any>();
@@ -89,7 +108,7 @@ export const ChartOptionsBuilder: React.FC<{
};
const type = Form.useWatch('type', form) ?? 'line';
const fieldOptions = useMemo(() => buildFieldOptions(columns || []), [columns]);
const fieldOptions = useMemo(() => fieldOptionsProp || buildFieldOptions(columns || []), [columns, fieldOptionsProp]);
return (
<div style={{ padding: 1 }}>
@@ -101,11 +120,7 @@ export const ChartOptionsBuilder: React.FC<{
onValuesChange={handleValuesChange}
>
{/* 图表类型 */}
<Form.Item
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Chart type'), lang)}</span>}
name="type"
required
>
<Form.Item label={renderLabel(t('Chart type'), lang)} name="type" required>
<Select
style={{ width: 180 }}
options={[
@@ -128,25 +143,13 @@ export const ChartOptionsBuilder: React.FC<{
{/* <Form.Item label={t('Height')} name="height">
<InputNumber min={100} style={{ width: 180 }} />
</Form.Item> */}
<Form.Item
name="legend"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Legend'), lang)}</span>}
>
<Form.Item name="legend" valuePropName="checked" label={renderLabel(t('Legend'), lang)}>
<Switch />
</Form.Item>
<Form.Item
name="tooltip"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Tooltip'), lang)}</span>}
>
<Form.Item name="tooltip" valuePropName="checked" label={renderLabel(t('Tooltip'), lang)}>
<Switch />
</Form.Item>
<Form.Item
name="label"
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Label'), lang)}</span>}
>
<Form.Item name="label" valuePropName="checked" label={renderLabel(t('Label'), lang)}>
<Switch />
</Form.Item>
</Form>
@@ -163,7 +166,7 @@ function renderItem(
return (
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey || spec.label || ''), lang)}</span>}
label={renderLabel(t(spec.labelKey || spec.label || ''), lang)}
name={spec.name}
required={spec.required}
>
@@ -178,23 +181,14 @@ function renderItem(
}
if (spec.kind === 'switch') {
return (
<Form.Item
key={spec.name}
name={spec.name}
valuePropName="checked"
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
>
<Form.Item key={spec.name} name={spec.name} valuePropName="checked" label={renderLabel(t(spec.labelKey), lang)}>
<Switch />
</Form.Item>
);
}
if (spec.kind === 'number') {
return (
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
name={spec.name}
>
<Form.Item key={spec.name} label={renderLabel(t(spec.labelKey), lang)} name={spec.name}>
<InputNumber min={spec.min} max={spec.max} style={{ width: 180 }} />
</Form.Item>
);
@@ -203,7 +197,7 @@ function renderItem(
const min = spec.min ?? 0;
const max = spec.max ?? 100;
return (
<Form.Item key={spec.name} label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}>
<Form.Item key={spec.name} label={renderLabel(t(spec.labelKey), lang)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Form.Item name={spec.name} style={{ margin: 0, paddingLeft: 6 }}>
<Slider min={min} max={max} step={1} style={{ width: 180 }} />
@@ -217,11 +211,7 @@ function renderItem(
}
if (spec.kind === 'enum') {
return (
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
name={spec.name}
>
<Form.Item key={spec.name} label={renderLabel(t(spec.labelKey), lang)} name={spec.name}>
<Select
style={{ width: 180 }}
options={(spec.options || []).map((o) => ({ label: t(o.labelKey || o.label || ''), value: o.value }))}
@@ -231,11 +221,7 @@ function renderItem(
}
if (spec.kind === 'segmented') {
return (
<Form.Item
key={spec.name}
label={<span style={{ fontWeight: 500 }}>{appendColon(t(spec.labelKey), lang)}</span>}
name={spec.name}
>
<Form.Item key={spec.name} label={renderLabel(t(spec.labelKey), lang)} name={spec.name}>
<Segmented
options={(spec.options || []).map((o) => ({ label: t(o.labelKey || o.label || ''), value: o.value }))}
/>
@@ -16,6 +16,25 @@ import { FunctionOutlined, LineChartOutlined } from '@ant-design/icons';
import { ChartOptionsBuilder } from './ChartOptionsBuilder';
import { configStore } from './config-store';
import { observer, useFlowSettingsContext } from '@nocobase/flow-engine';
import { useCompile, useDataSourceManager } from '@nocobase/client';
import { getFieldOptions } from './QueryBuilder.service';
const flattenFieldTitleMap = (options: any[] = [], prefix: string[] = [], map = new Map<string, string>()) => {
for (const option of options) {
if (!option?.name) continue;
const path = [...prefix, option.name];
map.set(path.join('.'), option.title || option.name);
if (option.children?.length) {
flattenFieldTitleMap(option.children, path, map);
}
}
return map;
};
const toFieldPath = (field: string | string[] | undefined) => {
if (!field) return '';
return Array.isArray(field) ? field.filter(Boolean).join('.') : field;
};
export const chartOptionDefaultValue = `return {
dataset: { source: ctx.data.objects || [] },
@@ -36,9 +55,38 @@ export const ChartOptionsPanel: React.FC = observer(() => {
const form = useForm();
// 从 flow ctx 和 configStore 计算 columns
const ctx = useFlowSettingsContext<any>();
const dm = useDataSourceManager();
const compile = useCompile();
const uid = ctx?.model?.uid;
const previewData = configStore.results[uid]?.result || [];
const columns = React.useMemo<string[]>(() => Object.keys(previewData?.[0] ?? {}), [previewData]);
const previewColumns = React.useMemo<string[]>(() => Object.keys(previewData?.[0] ?? {}), [previewData]);
const query = form?.values?.query;
const collectionPath = query?.collectionPath;
const fieldTitleMap = React.useMemo(() => {
return flattenFieldTitleMap(getFieldOptions(dm, compile, collectionPath));
}, [collectionPath, compile, dm]);
const columnOptions = React.useMemo(() => {
const items = [...(query?.dimensions || []), ...(query?.measures || [])];
const queryColumnMap = new Map<string, string>();
const derivedColumns: string[] = [];
for (const item of items) {
const fieldPath = toFieldPath(item?.field);
if (!fieldPath) continue;
const key = item?.alias || fieldPath;
queryColumnMap.set(key, item?.alias || fieldTitleMap.get(fieldPath) || fieldPath);
derivedColumns.push(key);
}
const columns = Array.from(new Set([...derivedColumns, ...previewColumns]));
return columns.map((column) => ({
value: column,
label: queryColumnMap.get(column) || fieldTitleMap.get(column) || column,
}));
}, [fieldTitleMap, previewColumns, query?.dimensions, query?.measures]);
// 受控 value 与回写 formily
const mode = form?.values?.chart?.option?.mode || 'basic';
@@ -128,7 +176,12 @@ export const ChartOptionsPanel: React.FC = observer(() => {
</div>
{mode === 'basic' ? (
<ChartOptionsBuilder columns={columns} initialValues={builderValue} onChange={handleBuilderChange} />
<ChartOptionsBuilder
columns={columnOptions.map((item) => item.value)}
fieldOptions={columnOptions}
initialValues={builderValue}
onChange={handleBuilderChange}
/>
) : (
<div>
<ChartOptionsEditor value={rawValue ?? chartOptionDefaultValue} onChange={handleRawChange} />
@@ -7,113 +7,295 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { useFlowSettingsContext } from '@nocobase/flow-engine';
import React, {
forwardRef,
type FC,
type ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { createCollectionContextMeta, observer, useFlowSettingsContext } from '@nocobase/flow-engine';
import { Form, Space, Cascader, Select, Input, Checkbox, Button, InputNumber } from 'antd';
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined, PlusOutlined } from '@ant-design/icons';
import isEqual from 'lodash/isEqual';
import { useT } from '../../locale';
import { useDataSourceManager, useCompile } from '@nocobase/client';
import { FilterGroup, VariableFilterItem, useCompile, useDataSourceManager } from '@nocobase/client';
import { useForm as useFormilyForm } from '@formily/react';
import {
getFieldOptions,
getCollectionOptions,
getFormatterOptionsByField,
buildOrderFieldOptions,
validateQuery,
} from './QueryBuilder.service';
import { appendColon, debugLog } from '../utils';
import AntdFilterSelector from '../components/AntdFilterSelector';
import { appendColon } from '../utils';
export type QueryBuilderRef = {
validate: () => Promise<any>;
};
export const QueryBuilder = React.forwardRef<
QueryBuilderRef,
{
initialValues?: any;
onChange?: (v: any) => void;
}
>(({ initialValues, onChange }, ref) => {
type QueryValue = {
collectionPath?: string[];
measures?: any[];
dimensions?: any[];
filter?: { logic: '$and' | '$or'; items: any[] };
orders?: any[];
limit?: number;
offset?: number;
};
const createEmptyFilter = () => ({
logic: '$and' as const,
items: [],
});
const renderLabel = (label: string, lang?: string) => {
return (
<div
style={{
width: '100%',
whiteSpace: 'normal',
wordBreak: 'break-word',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
fontWeight: 500,
}}
>
<span>{appendColon(label, lang)}</span>
</div>
);
};
const QueryFilter: FC<{
value?: QueryValue['filter'];
onChange?: (value: QueryValue['filter']) => void;
collectionPath?: string[];
}> = observer(({ value, onChange, collectionPath }) => {
const ctx = useFlowSettingsContext<any>();
const model = ctx.model;
const renderFilterItem = useCallback(
(props: any) => <VariableFilterItem {...props} model={model} rightAsVariable />,
[model],
);
useEffect(() => {
const [dataSourceKey, collectionName] = collectionPath || [];
if (dataSourceKey && collectionName && model) {
const collection = model?.context?.dataSourceManager?.getCollection(dataSourceKey, collectionName);
if (collection) {
model.context.defineProperty('collection', {
get: () => collection,
meta: createCollectionContextMeta(() => collection, ctx.t('Current collection')),
});
}
}
}, [collectionPath, ctx, model]);
return <FilterGroup value={value || createEmptyFilter()} onChange={onChange} FilterItem={renderFilterItem} />;
});
function ensureQueryShape(query?: QueryValue): Required<QueryValue> {
return {
collectionPath: query?.collectionPath || [],
measures: query?.measures || [],
dimensions: query?.dimensions || [],
filter: query?.filter || createEmptyFilter(),
orders: query?.orders || [],
limit: query?.limit as any,
offset: query?.offset as any,
};
}
const QueryBuilderInner: FC<{
forwardedRef: ForwardedRef<QueryBuilderRef>;
}> = observer(({ forwardedRef }) => {
const t = useT();
const stepForm = useFormilyForm();
const [form] = Form.useForm();
const ctx = useFlowSettingsContext<any>();
const lang = ctx?.i18n?.language;
React.useImperativeHandle(ref, () => ({
validate: () => form.validateFields(),
}));
// 从内表单读取 collectionPath,驱动字段树
const collectionPath = Form.useWatch('collectionPath', form);
// 构建集合选项(改为 service 纯函数)
const dm = useDataSourceManager();
const compile = useCompile();
const collectionOptions = React.useMemo(() => getCollectionOptions(dm, compile), [dm, compile]);
const fieldOptions = React.useMemo(() => getFieldOptions(dm, compile, collectionPath), [dm, compile, collectionPath]);
const measuresValue = Form.useWatch('measures', form);
const dimensionsValue = Form.useWatch('dimensions', form);
const rawQuery = stepForm.values?.query;
const query = useMemo(() => ensureQueryShape(rawQuery), [rawQuery]);
const collectionPath = query.collectionPath;
const measuresValue = query.measures;
const dimensionsValue = query.dimensions;
const orderFieldOptions = React.useMemo(
useEffect(() => {
const currentQuery = ensureQueryShape(form.getFieldsValue(true));
if (!isEqual(currentQuery, query)) {
form.setFieldsValue(query);
}
}, [form, query]);
useImperativeHandle(
forwardedRef,
() => ({
validate: async () => {
const candidate = { ...(stepForm.values?.query || {}), mode: 'builder' };
const { success, message } = validateQuery(candidate);
if (!success) {
throw new Error(message);
}
},
}),
[stepForm],
);
const collectionOptions = useMemo(() => getCollectionOptions(dm, compile), [dm, compile]);
const fieldOptions = useMemo(() => getFieldOptions(dm, compile, collectionPath), [dm, compile, collectionPath]);
const orderFieldOptions = useMemo(
() => buildOrderFieldOptions(fieldOptions, dimensionsValue, measuresValue),
[dimensionsValue, measuresValue, fieldOptions],
);
// 切换集合后,清理依赖旧集合的字段配置
const onCollectionChange = (val: any) => {
form.setFieldsValue({
collectionPath: val,
measures: [],
dimensions: [],
orders: [],
filter: undefined,
});
onChange?.(form.getFieldsValue(true));
};
const setQueryValue = useCallback(
(key: keyof QueryValue, value: any) => {
stepForm.setValuesIn?.(`query.${key}`, value);
},
[stepForm],
);
const handleValuesChange = (_: any, allValues: any) => {
debugLog('---handleValuesChange', allValues);
onChange?.(allValues);
};
const syncQuery = useCallback(
(patch: Partial<QueryValue>) => {
stepForm.setValuesIn?.('query', {
...(stepForm.values?.query || {}),
...patch,
});
},
[stepForm],
);
// 工具:数组上移/下移
const moveItem = (name: string, index: number, dir: -1 | 1) => {
const arr = form.getFieldValue(name) || [];
const target = index + dir;
if (target < 0 || target >= arr.length) return;
const next = arr.slice();
const [item] = next.splice(index, 1);
next.splice(target, 0, item);
form.setFieldsValue({ [name]: next });
onChange?.({ ...(form.getFieldsValue(true) || {}), [name]: next });
};
const moveItem = useCallback(
(key: 'measures' | 'dimensions' | 'orders', index: number, dir: -1 | 1) => {
const arr = [...((form.getFieldValue(key) as any[]) || [])];
const target = index + dir;
if (target < 0 || target >= arr.length) return;
const [item] = arr.splice(index, 1);
arr.splice(target, 0, item);
form.setFieldValue(key, arr);
syncQuery({ [key]: arr });
},
[form, syncQuery],
);
const handleCollectionChange = useCallback(
(val: any) => {
const nextQuery = {
...(stepForm.values?.query || {}),
collectionPath: val,
measures: [],
dimensions: [],
orders: [],
filter: createEmptyFilter(),
};
form.setFieldsValue(nextQuery);
stepForm.setValuesIn?.('query', nextQuery);
},
[form, stepForm],
);
const handleValuesChange = useCallback(
(_: any, allValues: QueryValue) => {
syncQuery(allValues);
},
[syncQuery],
);
return (
<div style={{ paddingTop: 8 }}>
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={handleValuesChange}>
{/* 设置:数据源/集合 */}
<Form.Item
name="collectionPath"
label={<span style={{ fontWeight: 500 }}>{appendColon(t('Collection'), lang)}</span>}
rules={[{ required: true }]}
>
<Form form={form} layout="vertical" colon component="div" initialValues={query} onValuesChange={handleValuesChange}>
<Form.Item label={renderLabel(t('Collection'), lang)} style={{ marginBottom: 16, paddingTop: 8 }}>
<Form.Item name="collectionPath" noStyle>
<Cascader
showSearch
placeholder={t('Collection')}
options={collectionOptions}
onChange={onCollectionChange}
value={collectionPath}
onChange={handleCollectionChange}
style={{ width: 222 }}
/>
</Form.Item>
</Form.Item>
{/* Measures */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Measures'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="measures">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => (
<Form.Item label={renderLabel(t('Measures'), lang)} style={{ marginBottom: 16 }}>
<Form.List name="measures">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => (
<Space align="center" size={[8, 4]} wrap={false} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
style={{ minWidth: 114 }}
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={fieldOptions}
/>
</Form.Item>
<Form.Item name={[field.name, 'aggregation']} style={{ marginBottom: 0 }}>
<Select
allowClear
style={{ minWidth: 75 }}
placeholder={t('Aggregation')}
options={[
{ label: t('Sum'), value: 'sum' },
{ label: t('Count'), value: 'count' },
{ label: t('Avg'), value: 'avg' },
{ label: t('Max'), value: 'max' },
{ label: t('Min'), value: 'min' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'alias']} style={{ marginBottom: 0 }}>
<Input style={{ minWidth: 75 }} placeholder={t('Alias')} />
</Form.Item>
<Form.Item name={[field.name, 'distinct']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox style={{ minWidth: 60 }}>{t('Distinct')}</Checkbox>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
{fields.length > 1 && (
<>
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('measures', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('measures', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</>
)}
</Space>
))}
</div>
<Button type="link" icon={<PlusOutlined />} onClick={() => add({})} style={{ marginTop: -8, padding: 0 }}>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</Form.Item>
<Form.Item label={renderLabel(t('Dimensions'), lang)} style={{ marginBottom: 16 }}>
<Form.List name="dimensions">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => {
const dimField = form.getFieldValue(['dimensions', field.name, 'field']);
const fmtOptions = getFormatterOptionsByField(dm, collectionPath, dimField);
return (
<Space align="center" size={[8, 4]} wrap={false} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
@@ -123,213 +305,128 @@ export const QueryBuilder = React.forwardRef<
options={fieldOptions}
/>
</Form.Item>
<Form.Item name={[field.name, 'aggregation']} style={{ marginBottom: 0 }}>
<Select
style={{ minWidth: 75 }}
placeholder={t('Aggregation')}
options={[
{ label: t('Sum'), value: 'sum' },
{ label: t('Count'), value: 'count' },
{ label: t('Avg'), value: 'avg' },
{ label: t('Max'), value: 'max' },
{ label: t('Min'), value: 'min' },
]}
/>
</Form.Item>
{fmtOptions?.length ? (
<Form.Item name={[field.name, 'format']} style={{ marginBottom: 0 }}>
<Select
placeholder={t('Format')}
popupMatchSelectWidth={false}
options={fmtOptions.map((o: any) => ({ label: o.label, value: o.value }))}
/>
</Form.Item>
) : null}
<Form.Item name={[field.name, 'alias']} style={{ marginBottom: 0 }}>
<Input style={{ minWidth: 75 }} placeholder={t('Alias')} />
</Form.Item>
<Form.Item name={[field.name, 'distinct']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox style={{ minWidth: 60 }}>{t('Distinct')}</Checkbox>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
{fields.length > 1 && (
<>
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('measures', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('measures', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</>
)}
</Space>
))}
</div>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</div>
{/* Dimensions 标题 */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Dimensions'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="dimensions">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => {
const fieldName = field.name;
const dimField = form.getFieldValue(['dimensions', fieldName, 'field']);
const fmtOptions = getFormatterOptionsByField(dm, collectionPath, dimField);
return (
<Space align="center" size={[8, 4]} wrap={false} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
style={{ minWidth: 114 }}
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={fieldOptions}
/>
</Form.Item>
{/* 仅当 fmtOptions 有值时展示 Format 选择项 */}
{fmtOptions?.length ? (
<Form.Item name={[field.name, 'format']} style={{ marginBottom: 0 }}>
<Select
placeholder={t('Format')}
popupMatchSelectWidth={false}
options={fmtOptions.map((o: any) => ({ label: o.label, value: o.value }))}
/>
</Form.Item>
) : null}
<Form.Item name={[field.name, 'alias']} style={{ marginBottom: 0 }}>
<Input style={{ minWidth: 75 }} placeholder={t('Alias')} />
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('dimensions', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('dimensions', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</Space>
);
})}
</div>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</div>
{/* Filter 标题 */}
<div style={{ fontWeight: 500, marginBottom: 8 }}>{appendColon(t('Filter'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.Item name="filter" style={{ overflow: 'auto' }}>
<AntdFilterSelector model={ctx.model} collectionPath={collectionPath} />
</Form.Item>
</div>
{/* Sort */}
<div style={{ fontWeight: 500, marginBottom: 4 }}>{appendColon(t('Sort'), lang)}</div>
<div style={{ marginBottom: 16 }}>
<Form.List name="orders">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => (
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={orderFieldOptions}
style={{ minWidth: 114 }}
/>
</Form.Item>
<Form.Item name={[field.name, 'order']} style={{ marginBottom: 0 }}>
<Select
defaultValue="ASC"
style={{ minWidth: 100 }}
options={[
{ label: 'ASC', value: 'ASC' },
{ label: 'DESC', value: 'DESC' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'nulls']} style={{ marginBottom: 0 }}>
<Select
defaultValue="default"
style={{ minWidth: 110 }}
options={[
{ label: t('Default'), value: 'default' },
{ label: t('NULLS first'), value: 'first' },
{ label: t('NULLS last'), value: 'last' },
]}
/>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('orders', idx, -1)}
onClick={() => moveItem('dimensions', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('orders', idx, 1)}
onClick={() => moveItem('dimensions', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</Space>
))}
</div>
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => add({})}
style={{ marginTop: -8, padding: 0 }}
>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</div>
);
})}
</div>
<Button type="link" icon={<PlusOutlined />} onClick={() => add({})} style={{ marginTop: -8, padding: 0 }}>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</Form.Item>
{/* Limit */}
<Form.Item name="limit" label={<span style={{ fontWeight: 500 }}>{appendColon(t('Limit'), lang)}</span>}>
<Form.Item label={renderLabel(t('Filter'), lang)} style={{ marginBottom: 16 }}>
<QueryFilter
value={query.filter}
onChange={(value) => setQueryValue('filter', value)}
collectionPath={collectionPath}
/>
</Form.Item>
<Form.Item label={renderLabel(t('Sort'), lang)} style={{ marginBottom: 16 }}>
<Form.List name="orders">
{(fields, { add, remove }) => (
<>
<div style={{ overflow: 'auto' }}>
{fields.map((field, idx) => (
<Space wrap align="center" size={[8, 4]} style={{ marginBottom: 8 }} key={field.key}>
<Form.Item name={[field.name, 'field']} style={{ marginBottom: 0 }}>
<Cascader
placeholder={t('Select Field')}
fieldNames={{ label: 'title', value: 'name', children: 'children' }}
options={orderFieldOptions}
style={{ minWidth: 114 }}
/>
</Form.Item>
<Form.Item name={[field.name, 'order']} style={{ marginBottom: 0 }}>
<Select
style={{ minWidth: 100 }}
options={[
{ label: 'ASC', value: 'ASC' },
{ label: 'DESC', value: 'DESC' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'nulls']} style={{ marginBottom: 0 }}>
<Select
style={{ minWidth: 110 }}
options={[
{ label: t('Default'), value: 'default' },
{ label: t('NULLS first'), value: 'first' },
{ label: t('NULLS last'), value: 'last' },
]}
/>
</Form.Item>
<Button size="small" type="text" onClick={() => remove(field.name)} icon={<DeleteOutlined />} />
<Button
size="small"
type="text"
disabled={idx === 0}
onClick={() => moveItem('orders', idx, -1)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={idx === fields.length - 1}
onClick={() => moveItem('orders', idx, 1)}
icon={<ArrowDownOutlined />}
/>
</Space>
))}
</div>
<Button type="link" icon={<PlusOutlined />} onClick={() => add({})} style={{ marginTop: -8, padding: 0 }}>
{t('Add field')}
</Button>
</>
)}
</Form.List>
</Form.Item>
<Form.Item label={renderLabel(t('Limit'), lang)} style={{ marginBottom: 16 }}>
<Form.Item name="limit" noStyle>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
</Form.Item>
{/* Offset */}
<Form.Item name="offset" label={<span style={{ fontWeight: 500 }}>{appendColon(t('Offset'), lang)}</span>}>
<Form.Item label={renderLabel(t('Offset'), lang)} style={{ marginBottom: 16 }}>
<Form.Item name="offset" noStyle>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
</Form>
</div>
</Form.Item>
</Form>
);
});
export const QueryBuilder = forwardRef<QueryBuilderRef>((_props, ref) => {
return <QueryBuilderInner forwardedRef={ref} />;
});
@@ -13,7 +13,7 @@ import { SQLEditor } from './SQLEditor';
import { Radio, Button, Space } from 'antd';
import { useT } from '../../locale';
import { BuildOutlined, ConsoleSqlOutlined, RightOutlined, DownOutlined, RightSquareOutlined } from '@ant-design/icons';
import { QueryBuilder } from './QueryBuilder';
import { QueryBuilder, QueryBuilderRef } from './QueryBuilder';
import { ResultPanel } from './ResultPanel';
import { observer, useFlowSettingsContext } from '@nocobase/flow-engine';
import { configStore } from './config-store';
@@ -47,7 +47,7 @@ export const QueryPanel: React.FC = observer(() => {
const form = useForm();
const ctx = useFlowSettingsContext<any>();
const mode = form?.values?.query?.mode || 'builder';
const qbRef = React.useRef(null);
const qbRef = React.useRef<QueryBuilderRef>(null);
const [showResult, setShowResult] = useState(false);
const [running, setRunning] = useState(false);
@@ -101,16 +101,6 @@ export const QueryPanel: React.FC = observer(() => {
}
}, [mode, form]);
// 图形化模式
const handleBuilderChange = async (next: any) => {
const query = form?.values?.query || {};
form?.setValuesIn?.('query', {
...next,
mode: query.mode,
sql: query.sql,
});
};
// SQL 模式
// const handleSqlChange = async (sql: string) => {
// form?.setValuesIn?.('query.sql', sql);
@@ -190,7 +180,7 @@ export const QueryPanel: React.FC = observer(() => {
<ResultPanel />
</div>
) : mode === 'builder' ? (
<QueryBuilder ref={qbRef} initialValues={form?.values?.query} onChange={handleBuilderChange} />
<QueryBuilder ref={qbRef} />
) : (
<Field name="sql" component={[SQLEditor]} />
)}
@@ -42,7 +42,11 @@ const SQLEditorBase: React.FC<any> = observer((props) => {
return (
<div>
{/* 选择数据源 */}
<Form.Item label={t('Data source')} rules={[{ required: true }]} style={{ marginTop: 8 }}>
<Form.Item
label={<span style={{ fontWeight: 500 }}>{t('Data source')}</span>}
rules={[{ required: true }]}
style={{ marginTop: 8 }}
>
<Select
style={{ width: 222 }}
placeholder={t('Data source')}
@@ -116,6 +116,7 @@ export class ChartResource<TData = any> extends BaseRecordResource<TData> {
orders: query.orders,
limit: query.limit,
offset: query.offset,
contextParams: query.contextParams,
};
return data;
}
@@ -334,6 +334,38 @@ describe('query', () => {
const userId = filter.$and[1].userId.$eq;
expect(userId).toBe(user.id);
});
it('should reuse flow-engine variable resolver for filter values', async () => {
const user = await db.getRepository('users').findOne();
const context = {
...ctx,
auth: {
user,
},
state: {
currentUser: user,
},
get: (key: string) => {
return {
'x-timezone': '',
}[key];
},
getCurrentLocale: () => 'en-US',
action: {
params: {
values: {
filter: {
userId: { $eq: '{{ ctx.user.id }}' },
},
},
},
},
};
await parseVariables(context, async () => {});
expect(context.action.params.values.filter.userId.$eq).toBe(user.id);
});
});
describe('cacheMiddleware', () => {
@@ -16,6 +16,7 @@ import { QueryParams } from '../types';
import { createQueryParser } from '../query-parser';
import { assign } from '@nocobase/utils';
import { checkFilterParams, NoPermissionError } from '@nocobase/acl';
import { resolveVariablesTemplate } from '@nocobase/plugin-flow-engine';
const getDB = (ctx: Context, dataSource: string) => {
const ds = ctx.app.dataSourceManager.dataSources.get(dataSource);
@@ -214,6 +215,15 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
};
export const parseVariables = async (ctx: Context, next: Next) => {
const { mode, contextParams, ...values } = ctx.action.params.values as QueryParams;
if (mode !== 'sql') {
const resolvedValues = await resolveVariablesTemplate(ctx as any, values as any, contextParams || {});
ctx.action.params.values = {
...ctx.action.params.values,
...(resolvedValues as Record<string, any>),
};
}
const { filter } = ctx.action.params.values;
ctx.action.params.filter = filter;
await middlewares.parseVariables(ctx, async () => {
@@ -32,12 +32,14 @@ export type OrderProps = {
export type QueryParams = Partial<{
uid: string;
mode: 'builder' | 'sql';
dataSource: string;
collection: string;
measures: MeasureProps[];
dimensions: DimensionProps[];
orders: OrderProps[];
filter: any;
contextParams: Record<string, unknown>;
limit: number;
offset: number;
sql: {
@@ -10,3 +10,4 @@
export { default } from './plugin';
export { FlowModelRepository } from './repository';
export { FlowSchemaService } from './flow-schema-service';
export { resolveVariablesBatch, resolveVariablesTemplate } from './variables/resolve';
@@ -13,13 +13,10 @@ import type { ResourcerContext } from '@nocobase/resourcer';
import { parseLiquidContext, transformSQL } from '@nocobase/utils';
import { flowSchemaContribution } from './flow-schema-contributions';
import PluginUISchemaStorageServer from './server';
import { GlobalContext, HttpRequestContext } from './template/contexts';
import { JSONValue, resolveJsonTemplate } from './template/resolver';
import { variables } from './variables/registry';
import { prefetchRecordsForResolve } from './variables/utils';
import { JSONValue } from './template/resolver';
import { resolveVariablesBatch, resolveVariablesTemplate } from './variables/resolve';
export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
private globalContext!: GlobalContext;
async afterAdd() {}
getFlowSchemaContributions(): FlowSchemaContribution {
@@ -44,8 +41,6 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
this.app.auditManager.registerAction('flowSql:save');
this.app.auditManager.registerAction('flowModels:save');
this.app.auditManager.registerAction('flowModels:duplicate');
// Initialize a shared GlobalContext once, using server environment variables
this.globalContext = new GlobalContext(this.app.environment?.getVariables?.());
this.app.acl.allow('flowSql', 'runById', 'loggedIn');
this.app.acl.allow('flowSql', 'getBind', 'loggedIn');
this.app.acl.allow('variables', 'resolve', 'loggedIn');
@@ -70,23 +65,7 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
template: JSONValue;
contextParams?: Record<string, unknown>;
}>;
await prefetchRecordsForResolve(
ctx as ResourcerContext,
batchItems.map((it) => ({
template: it.template,
contextParams: (it.contextParams || {}) as Record<string, unknown>,
})),
);
const results: Array<{ id?: string | number; data: unknown }> = [];
for (const item of batchItems) {
const template = item?.template ?? {};
const contextParams = item?.contextParams || {};
const requestCtx = new HttpRequestContext(ctx);
requestCtx.delegate(this.globalContext);
await variables.attachUsedVariables(requestCtx, ctx, template, contextParams);
const resolved = await resolveJsonTemplate(template, requestCtx);
results.push({ id: item?.id, data: resolved });
}
const results = await resolveVariablesBatch(ctx as ResourcerContext, batchItems);
ctx.body = { results };
await next();
return;
@@ -101,12 +80,7 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
}
const template = values.template as JSONValue;
const contextParams = values?.contextParams || {};
await prefetchRecordsForResolve(ctx as ResourcerContext, [{ template, contextParams }]);
const requestCtx = new HttpRequestContext(ctx);
requestCtx.delegate(this.globalContext);
await variables.attachUsedVariables(requestCtx, ctx, template, contextParams);
const resolved = await resolveJsonTemplate(template, requestCtx);
ctx.body = resolved;
ctx.body = await resolveVariablesTemplate(ctx as ResourcerContext, template, contextParams);
await next();
},
},
@@ -0,0 +1,71 @@
/**
* 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 type { ResourcerContext } from '@nocobase/resourcer';
import { GlobalContext, HttpRequestContext } from '../template/contexts';
import { JSONValue, resolveJsonTemplate } from '../template/resolver';
import { variables } from './registry';
import { prefetchRecordsForResolve } from './utils';
type ResolveBatchItem = {
id?: string | number;
template: JSONValue;
contextParams?: Record<string, unknown>;
};
const GLOBAL_CONTEXT_KEY = Symbol.for('nocobase.flow-engine.variables.global-context');
function getGlobalContext(ctx: ResourcerContext) {
const app = ctx.app as typeof ctx.app & { [GLOBAL_CONTEXT_KEY]?: GlobalContext };
if (!app[GLOBAL_CONTEXT_KEY]) {
app[GLOBAL_CONTEXT_KEY] = new GlobalContext(app.environment?.getVariables?.());
}
return app[GLOBAL_CONTEXT_KEY] as GlobalContext;
}
export async function resolveVariablesTemplate(
ctx: ResourcerContext,
template: JSONValue,
contextParams: Record<string, unknown> = {},
) {
await prefetchRecordsForResolve(ctx, [{ template, contextParams }]);
return resolveVariablesTemplateWithPrefetchedRecords(ctx, template, contextParams);
}
async function resolveVariablesTemplateWithPrefetchedRecords(
ctx: ResourcerContext,
template: JSONValue,
contextParams: Record<string, unknown> = {},
) {
const requestCtx = new HttpRequestContext(ctx);
requestCtx.delegate(getGlobalContext(ctx));
await variables.attachUsedVariables(requestCtx, ctx, template, contextParams);
return resolveJsonTemplate(template, requestCtx);
}
export async function resolveVariablesBatch(ctx: ResourcerContext, items: ResolveBatchItem[]) {
await prefetchRecordsForResolve(
ctx,
items.map((item) => ({
template: item?.template ?? {},
contextParams: (item?.contextParams || {}) as Record<string, unknown>,
})),
);
const results: Array<{ id?: string | number; data: unknown }> = [];
for (const item of items) {
const data = await resolveVariablesTemplateWithPrefetchedRecords(
ctx,
item?.template ?? {},
(item?.contextParams || {}) as Record<string, unknown>,
);
results.push({ id: item?.id, data });
}
return results;
}