mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
Merge branch 'next' into develop
This commit is contained in:
@@ -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"
|
||||
|
||||
-134
@@ -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;
|
||||
+24
-9
@@ -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({
|
||||
|
||||
+31
-45
@@ -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 }))}
|
||||
/>
|
||||
|
||||
+55
-2
@@ -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} />
|
||||
|
||||
+351
-254
@@ -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} />;
|
||||
});
|
||||
|
||||
+3
-13
@@ -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]} />
|
||||
)}
|
||||
|
||||
+5
-1
@@ -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')}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+32
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user