feat: add queryBuild hooks

This commit is contained in:
Ziqiang
2025-09-20 11:45:47 +08:00
parent 2bac4234ef
commit ab717450ba
6 changed files with 793 additions and 86 deletions
@@ -0,0 +1,61 @@
/**
* 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 { Form } from 'antd';
import { useField } from '@formily/react';
export type FormItemLiteProps = React.ComponentProps<typeof Form.Item>;
const statusMap: Record<string, 'success' | 'warning' | 'error' | 'validating' | undefined> = {
success: 'success',
warning: 'warning',
error: 'error',
pending: 'validating',
};
export const FormItemLite: React.FC<FormItemLiteProps> = ({ children, ...rest }) => {
const field: any = useField();
// 可见性控制
if (field?.visible === false || field?.display === 'none' || field?.display === 'hidden') {
return null;
}
const label = rest.label ?? field?.title;
const required = rest.required ?? field?.required;
const validateStatus = rest.validateStatus ?? (field?.validateStatus ? statusMap[field.validateStatus] : undefined);
const help =
rest.help ??
field?.feedbackText ??
(Array.isArray(field?.selfErrors) && field.selfErrors[0]) ??
(Array.isArray(field?.selfWarnings) && field.selfWarnings[0]) ??
undefined;
const extra = rest.extra ?? field?.description;
return (
<Form.Item
{...rest}
// 关键:减少底部间距,便于与行内 Icon 按钮垂直对齐
style={{ marginBottom: 0, ...(rest as any).style }}
label={label}
required={required}
validateStatus={validateStatus}
help={help}
extra={extra}
>
{children}
</Form.Item>
);
};
export default FormItemLite;
@@ -7,95 +7,378 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import { ArrayItems, FormCollapse, FormItem, Space, Switch, Cascader, Select, Input, Checkbox } from '@formily/antd-v5';
import { Schema, useForm } from '@formily/react';
import { SchemaComponent, InputNumber, Filter } from '@nocobase/client';
import { theme } from 'antd';
import { useT } from '../../locale';
import { querySchema } from '../../configure/schemas/configure';
import {
useCollectionFieldsOptions,
useCollectionFilterOptions,
useCollectionOptions,
useFieldsWithAssociation,
useFormatters,
useOrderFieldsOptions,
useOrderReaction,
} from '../../hooks';
import { FilterDynamicComponent } from '../../configure/FilterDynamicComponent';
export const QueryBuilder: React.FC = () => {
const t = useT();
const form = useForm();
const { token } = theme.useToken();
// 获取当前选中的数据源/集合(来自 schema: settings.collection 的 Cascader)
const collectionPath: string[] | undefined = form?.values?.settings?.collection;
const [dataSource, collection] = collectionPath || [];
// 计算 schema scope 所需的数据与方法
const fields = useFieldsWithAssociation(dataSource, collection);
const fieldOptionsRaw = useCollectionFieldsOptions(dataSource, collection, 1);
const fieldOptions = useMemo(() => Schema.compile(fieldOptionsRaw, { t }), [fieldOptionsRaw, t]);
const filterOptions = useCollectionFilterOptions(dataSource, collection);
const collectionOptions = useCollectionOptions();
const formCollapse = FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']);
const useFormatterOptions = useFormatters(fields);
const useOrderOptions = useOrderFieldsOptions(fieldOptions, fields);
const useOrderReactionHook = useOrderReaction(fieldOptions, fields);
// 切换集合时,清空 builder 查询配置,避免跨集合污染
const onCollectionChange = (value: string[]) => {
form.setValues({
...form.values,
settings: {
...(form.values?.settings || {}),
collection: value,
},
query: {
...(form.values?.query || {}),
builder: {
measures: [],
dimensions: [],
filter: undefined,
orders: [],
},
},
});
};
import React from 'react';
import { Collapse } from 'antd';
import { Field, ObjectField, ArrayField } from '@formily/react';
import { InputNumber, Filter } from '@nocobase/client';
import { useQueryBuilderLogic } from './queryBuilder.logic';
import { Space, Cascader, Select, Input, Checkbox, Button } from 'antd';
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
// 极简适配器:让 antd 原生组件符合 Formily 的 value/onChange 协议,并对 dataSource 做 options 映射
// 顶部适配器定义区域
const CascaderAdapter: React.FC<any> = ({ dataSource, onChange, onValueChange, ...rest }) => {
return (
<SchemaComponent
schema={querySchema}
scope={{
t,
formCollapse,
fieldOptions,
filterOptions,
useOrderOptions,
collectionOptions,
useFormatterOptions,
onCollectionChange,
collection,
useOrderReaction: useOrderReactionHook,
collapsePanelBg: token.colorBgContainer,
}}
components={{
ArrayItems,
FormCollapse,
FormItem,
Space,
Cascader,
Select,
Input,
InputNumber,
Checkbox,
Switch,
Filter,
FilterDynamicComponent,
<Cascader
{...rest}
options={dataSource}
onChange={(v) => {
onChange?.(v); // 先写入 Formily 表单值
onValueChange?.(v); // 再触发业务侧副作用
}}
/>
);
};
const SelectAdapter: React.FC<any> = ({ dataSource, onChange, onValueChange, ...rest }) => {
return (
<Select
{...rest}
options={dataSource}
onChange={(v) => {
onChange?.(v);
onValueChange?.(v);
}}
/>
);
};
const InputAdapter: React.FC<any> = ({ onChange, onValueChange, ...rest }) => {
return (
<Input
{...rest}
onChange={(e) => {
const v = e?.target?.value;
onChange?.(v);
onValueChange?.(v);
}}
/>
);
};
const CheckboxAdapter: React.FC<any> = ({ value, onChange, onValueChange, content, children, ...rest }) => {
return (
<Checkbox
{...rest}
checked={!!value}
onChange={(e) => {
const v = e?.target?.checked;
onChange?.(v);
onValueChange?.(v);
}}
>
{children ?? content}
</Checkbox>
);
};
import FormItemLite from './FormItemLite';
export const QueryBuilder: React.FC = () => {
const {
t,
token,
collectionOptions,
fieldOptions,
filterOptions,
formCollapse, // 暂时保留(未使用),后续步骤再考虑移除
useFormatterOptions,
useOrderOptions,
useOrderReactionHook,
onCollectionChange,
} = useQueryBuilderLogic();
return (
<>
{/* 设置:数据源/集合 */}
<ObjectField name="settings">
<Field
name="collection"
title={t('Collection')}
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
showSearch: true,
placeholder: t('Collection'),
dataSource: collectionOptions,
onValueChange: onCollectionChange,
},
]}
/>
</ObjectField>
{/* 查询构建 */}
<ObjectField name="query">
<ObjectField name="builder">
<Collapse
size="small"
bordered={false}
defaultActiveKey={['measures']}
style={{ border: 'none', boxShadow: 'none' }}
>
{/* Measures */}
<Collapse.Panel key="measures" header={t('Measures')} style={{ background: token.colorBgContainer }}>
<ArrayField name="measures" required>
{(field) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]}>
<Field
name="field"
required
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
placeholder: t('Select Field'),
fieldNames: { label: 'title', value: 'name', children: 'children' },
dataSource: fieldOptions,
},
]}
/>
<Field
name="aggregation"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
placeholder: t('Aggregation'),
style: { minWidth: 80 },
dataSource: [
{ 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' },
],
},
]}
/>
<Field
name="alias"
decorator={[FormItemLite]}
component={[InputAdapter, { placeholder: t('Alias'), style: { minWidth: 100 } }]}
/>
<Field
name="distinct"
decorator={[FormItemLite]}
component={[CheckboxAdapter]}
content={t('Distinct')}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
))}
</div>
<Button type="dashed" onClick={() => field.push({})}>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
{/* Dimensions */}
<Collapse.Panel key="dimensions" header={t('Dimensions')} style={{ background: token.colorBgContainer }}>
<ArrayField name="dimensions">
{(field) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]}>
<Field
name="field"
required
decorator={[FormItemLite]}
component={[
CascaderAdapter,
{
placeholder: t('Select Field'),
fieldNames: { label: 'title', value: 'name', children: 'children' },
dataSource: fieldOptions,
},
]}
/>
<Field
name="format"
decorator={[FormItemLite]}
reactions={[
useFormatterOptions,
(f) => {
// 仅当存在可选项时显示
// @ts-ignore
f.visible = !!(f.dataSource && f.dataSource.length);
},
]}
component={[SelectAdapter, { placeholder: t('Format'), style: { maxWidth: 120 } }]}
/>
<Field
name="alias"
decorator={[FormItemLite]}
component={[InputAdapter, { placeholder: t('Alias'), style: { minWidth: 100 } }]}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
))}
</div>
<Button type="dashed" onClick={() => field.push({})}>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
{/* Filter */}
<Collapse.Panel key="filter" header={t('Filter')} style={{ background: token.colorBgContainer }}>
<Field
name="filter"
decorator={[FormItemLite, { style: { overflow: 'auto' } }]}
component={[Filter, { dynamicComponent: 'FilterDynamicComponent', dataSource: filterOptions }]}
/>
</Collapse.Panel>
{/* Sort */}
<Collapse.Panel
key="sort"
header={t('Sort')}
style={{ background: token.colorBgContainer, border: 'none' }}
>
<ArrayField name="orders" reactions={[useOrderReactionHook]}>
{(field) => (
<>
<div style={{ overflow: 'auto' }}>
{field.value?.map((item, index) => (
<ObjectField name={index} key={index}>
<Space wrap align="center" size={[8, 4]}>
<Field
name="field"
required
decorator={[FormItemLite]}
reactions={[useOrderOptions]}
component={[CascaderAdapter, { placeholder: t('Select Field') }]}
/>
<Field
name="order"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
defaultValue: 'ASC',
dataSource: [
{ label: 'ASC', value: 'ASC' },
{ label: 'DESC', value: 'DESC' },
],
style: { minWidth: 100 },
},
]}
/>
<Field
name="nulls"
decorator={[FormItemLite]}
component={[
SelectAdapter,
{
defaultValue: 'default',
dataSource: [
{ label: t('Default'), value: 'default' },
{ label: t('NULLS first'), value: 'first' },
{ label: t('NULLS last'), value: 'last' },
],
style: { minWidth: 110 },
},
]}
/>
<Button
size="small"
type="text"
onClick={() => field.remove(index)}
icon={<DeleteOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === 0}
onClick={() => field.moveUp(index)}
icon={<ArrowUpOutlined />}
/>
<Button
size="small"
type="text"
disabled={index === (field.value?.length ?? 0) - 1}
onClick={() => field.moveDown(index)}
icon={<ArrowDownOutlined />}
/>
</Space>
</ObjectField>
))}
</div>
<Button type="dashed" onClick={() => field.push({})}>
{t('Add field')}
</Button>
</>
)}
</ArrayField>
</Collapse.Panel>
</Collapse>
</ObjectField>
{/* Limit / Offset */}
<Field
name="limit"
title={t('Limit')}
decorator={[FormItemLite]}
component={[InputNumber, { defaultValue: 2000, min: 1, style: { width: 100 } }]}
/>
<Field
name="offset"
title={t('Offset')}
decorator={[FormItemLite]}
component={[InputNumber, { defaultValue: 0, min: 0, style: { width: 100 } }]}
/>
</ObjectField>
</>
);
};
@@ -0,0 +1,290 @@
/**
* 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 { useMemo } from 'react';
import { FormCollapse } from '@formily/antd-v5';
import { useForm } from '@formily/react';
import { theme } from 'antd';
import { useT } from '../../locale';
import { DEFAULT_DATA_SOURCE_KEY, useDataSourceManager } from '@nocobase/client';
import { formatters } from '../utils';
export const useQueryBuilderLogic = () => {
const t = useT();
const form = useForm();
const { token } = theme.useToken();
const dm = useDataSourceManager();
// 读取当前选择的数据源与集合
const collectionPath: string[] | undefined = form?.values?.settings?.collection;
const [dataSourceKey, collectionName] = collectionPath || [];
const ds = dm.getDataSource(dataSourceKey || DEFAULT_DATA_SOURCE_KEY);
const cm = ds?.collectionManager;
const fim = dm.collectionFieldInterfaceManager;
// 构建集合选项(数据源 -> 集合),不再进行 ACL 过滤
const collectionOptions = useMemo(() => {
const allCollections = dm.getAllCollections();
return allCollections
.filter(({ key, isDBInstance }) => key === DEFAULT_DATA_SOURCE_KEY || isDBInstance)
.map(({ key, displayName, collections }) => ({
value: key,
label: displayName,
children: (collections || []).map((c) => ({
value: c.name,
label: c.title ?? c.name,
})),
}));
}, [dm]);
// 工具:拼接级联字段别名
const aliasOf = (val: any): string => {
if (Array.isArray(val)) return val.filter(Boolean).join('.');
return val ? String(val) : '';
};
// 字段树(用于 Cascader):最多一层关联
const fieldOptions = useMemo(() => {
if (!cm || !fim || !collectionName) return [];
const collectionFields = cm.getCollectionFields(collectionName) || [];
const toOption = (field: any, depth: number, prefix?: string) => {
if (!field?.interface) return undefined;
const iface = fim.getFieldInterface(field.interface);
if (!iface?.filterable) return undefined;
const value = prefix ? `${prefix}.${field.name}` : field.name;
const opt: any = {
name: field.name,
title: field?.uiSchema?.title || field.name,
key: value,
value: field.name,
};
// 限制最大深度为 1(与原 QueryBuilder 使用一致)
if (depth >= 1) {
// children: filterable.children(如 year/month 等虚拟子项)
const children = iface.filterable?.children || [];
if (children.length) {
opt.children = children.map((c: any) => ({
...c,
key: `${field.name}.${c.name}`,
value: c.name,
}));
}
// 关联目标字段作为 children
if (iface.filterable?.nested && field.target) {
const targetFields = cm.getCollectionFields(field.target) || [];
const nested = targetFields.map((tf) => toOption(tf, depth + 1, field.name)).filter(Boolean);
opt.children = [...(opt.children || []), ...nested];
}
}
return opt;
};
return collectionFields.map((f) => toOption(f, 0)).filter(Boolean);
}, [cm, fim, collectionName, form?.values?.settings?.collection]);
// 过滤器字段与操作符(Filter 用):最多两层关联
const filterOptions = useMemo(() => {
if (!cm || !fim || !collectionName) return [];
const fields = cm.getCollectionFields(collectionName) || [];
const toOption = (field: any, depth: number): any => {
if (!field?.interface) return undefined;
const iface = fim.getFieldInterface(field.interface);
if (!iface?.filterable) return undefined;
const ops = (iface.filterable.operators || []).filter((op: any) => !op?.visible || op.visible(field)) || [];
const opt: any = {
name: field.name,
title: field?.uiSchema?.title || field.name,
schema: field?.uiSchema,
operators: ops,
interface: field.interface,
};
if (depth >= 2) return opt;
// children: filterable.children(虚拟)
if (iface.filterable?.children?.length) {
opt.children = iface.filterable.children;
}
// 嵌套关联
if (iface.filterable?.nested && field.target) {
const targetFields = cm.getCollectionFields(field.target) || [];
const nested = targetFields.map((tf) => toOption(tf, depth + 1)).filter(Boolean);
opt.children = [...(opt.children || []), ...nested];
}
return opt;
};
return fields.map((f) => toOption(f, 0)).filter(Boolean);
}, [cm, fim, collectionName, form?.values?.settings?.collection]);
// 根据字段别名推断 interface(仅处理实际字段/一层关联)
const getInterfaceByAlias = (alias: string): string | undefined => {
if (!alias || !cm || !collectionName) return;
const parts = alias.split('.');
const [first, second] = parts;
const rootFields = cm.getCollectionFields(collectionName) || [];
const root = rootFields.find((f) => f.name === first);
if (!root) return;
if (!second) return root.interface;
if (root.target) {
const targetFields = cm.getCollectionFields(root.target) || [];
const child = targetFields.find((f) => f.name === second);
return child?.interface;
}
return undefined;
};
// 维度格式化选项 reactions
const useFormatterOptions = (field: any) => {
const selected = field.query('.field').get('value');
const alias = aliasOf(selected);
if (!alias) {
field.dataSource = [];
return;
}
const iface = getInterfaceByAlias(alias);
switch (iface) {
case 'datetime':
case 'datetimeTz':
case 'unixTimestamp':
case 'datetimeNoTz':
case 'createdAt':
case 'updatedAt':
field.dataSource = formatters.datetime;
return;
case 'date':
field.dataSource = formatters.date;
return;
case 'time':
field.dataSource = formatters.time;
return;
default:
field.dataSource = [];
}
};
// 展开 fieldOptions 为别名集合(非聚合排序用)
const flattenAllAliases = (options: any[], prefix?: string): string[] => {
const acc: string[] = [];
options.forEach((opt) => {
const self = prefix ? `${prefix}.${opt.value || opt.name}` : opt.value || opt.name;
if (opt.children?.length) {
acc.push(...flattenAllAliases(opt.children, opt.name));
} else if (self) {
acc.push(self);
}
});
return acc;
};
// 从当前表单 query 中提取选中的字段别名(聚合时作为排序字段列表)
const getSelectedAliases = (query: any): string[] => {
const aliases: string[] = [];
const dims = query?.dimensions || [];
const meas = query?.measures || [];
dims.forEach((d: any) => {
const a = aliasOf(d?.field);
if (a) aliases.push(a);
});
meas.forEach((m: any) => {
const a = m?.alias || aliasOf(m?.field);
if (a) aliases.push(a);
});
// 去重
return Array.from(new Set(aliases));
};
// 排序字段候选 reactions
const useOrderOptions = (field: any) => {
const query = field.query('query').get('value') || {};
const hasAgg = (query.measures || []).some((m: any) => !!m?.aggregation);
if (hasAgg) {
// 聚合:排序字段为维度与度量(用别名表示)
const selected = getSelectedAliases(query);
field.componentProps.fieldNames = {}; // 扁平 options
field.dataSource = selected.map((v) => ({ label: v, value: v }));
return;
}
// 非聚合:使用完整字段树
field.componentProps.fieldNames = { label: 'title', value: 'name', children: 'children' };
field.dataSource = fieldOptions;
};
// 排序数组合法性校验 reactions
const useOrderReactionHook = (arrayField: any) => {
const query = arrayField.query('query').get('value') || {};
const hasAgg = (query.measures || []).some((m: any) => !!m?.aggregation);
const allowed = hasAgg ? new Set(getSelectedAliases(query)) : new Set(flattenAllAliases(fieldOptions));
const orders = arrayField.value || [];
const next = orders.filter((item: any) => {
const a = aliasOf(item?.field);
// 修复点:未选择字段(a 为空)时保留该条目,避免刚 push 就被清洗掉
if (!a) return true;
return allowed.has(a);
});
// 仅在结果有变化时再写回,避免不必要的 set
const changed = next.length !== orders.length || next.some((v: any, i: number) => v !== orders[i]);
if (changed) {
arrayField.setValue(next);
}
};
// 切换集合:清空 builder 配置
const onCollectionChange = (value: string[]) => {
form.setValues({
...form.values,
settings: {
...(form.values?.settings || {}),
collection: value,
},
query: {
...(form.values?.query || {}),
builder: {
measures: [],
dimensions: [],
filter: undefined,
orders: [],
},
},
});
};
return {
t,
token,
// 暴露给 UI 的数据与 reactions
collectionOptions,
fieldOptions,
filterOptions,
formCollapse: FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']),
useFormatterOptions,
useOrderOptions,
useOrderReactionHook,
onCollectionChange,
};
};
@@ -7,6 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { lang } from '../locale';
export function convertDatasetFormats(data: Record<string, any>[]) {
if (!Array.isArray(data) || data.length === 0) {
return {
@@ -40,3 +42,72 @@ export function convertDatasetFormats(data: Record<string, any>[]) {
columns,
};
}
export const formatters = {
datetime: [
{
label: lang('YYYY'),
value: 'YYYY',
},
{
label: lang('MM'),
value: 'MM',
},
{
label: lang('DD'),
value: 'DD',
},
{
label: lang('YYYY-MM'),
value: 'YYYY-MM',
},
{
label: lang('YYYY-MM-DD'),
value: 'YYYY-MM-DD',
},
{
label: lang('YYYY-MM-DD hh:mm'),
value: 'YYYY-MM-DD hh:mm',
},
{
label: lang('YYYY-MM-DD hh:mm:ss'),
value: 'YYYY-MM-DD hh:mm:ss',
},
],
date: [
{
label: lang('YYYY'),
value: 'YYYY',
},
{
label: lang('MM'),
value: 'MM',
},
{
label: lang('DD'),
value: 'DD',
},
{
label: lang('YYYY-MM'),
value: 'YYYY-MM',
},
{
label: lang('YYYY-MM-DD'),
value: 'YYYY-MM-DD',
},
],
time: [
{
label: lang('hh:mm:ss'),
value: 'hh:mm:ss',
},
{
label: lang('hh:mm'),
value: 'hh:mm',
},
{
label: lang('hh'),
value: 'hh',
},
],
};
@@ -27,6 +27,7 @@
"Enable cache": "Enable cache",
"TTL (second)": "TTL (second)",
"Field": "Field",
"Select Field": "Select Field",
"Aggregation": "Aggregation",
"Alias": "Alias",
"Format": "Format",
@@ -27,6 +27,7 @@
"Enable cache": "启用缓存",
"TTL (second)": "缓存时间 (秒)",
"Field": "字段",
"Select Field": "选择字段",
"Aggregation": "聚合",
"Alias": "别名",
"Format": "格式",