From 088f3977a2108fee4edab29c480c6c6b59aa7b68 Mon Sep 17 00:00:00 2001 From: YANG QIA <2013xile@gmail.com> Date: Mon, 25 Mar 2024 14:54:13 +0800 Subject: [PATCH] feat(data-vi): support multiple data sources (#3743) * feat(data-vi): support multiple data sources * chore: update * chore: new pr * chore: update * chore: merge * fix: bug * fix: isDBInstance * fix: fix T-3624 * fix: fix T-3625 * fix: test * fix: fix T-3659 * fix: fix T-3660 * fix: backend tests * fix: acl * fix: fix T-3680 * fix: build --- .../src/data-source/data-source/DataSource.ts | 1 + .../schema-settings/GeneralSchemaDesigner.tsx | 15 +- .../src/client/__tests__/hooks.test.ts | 136 ++++----- .../src/client/block/ChartBlockDesigner.tsx | 2 +- .../client/block/ChartBlockInitializer.tsx | 8 +- .../src/client/block/ChartDataProvider.tsx | 5 +- .../src/client/chart/antd/table.ts | 14 +- .../src/client/chart/g2plot/g2plot.ts | 4 +- .../src/client/configure/ChartConfigure.tsx | 44 +-- .../src/client/configure/schemas/configure.ts | 4 +- .../filter/CollectionFieldInitializer.tsx | 9 + .../src/client/filter/FilterBlockDesigner.tsx | 2 +- .../src/client/filter/FilterBlockProvider.tsx | 2 + .../src/client/filter/FilterForm.tsx | 7 +- .../src/client/filter/FilterItemDesigner.tsx | 22 +- .../client/filter/FilterItemInitializers.tsx | 149 +++++----- .../src/client/filter/FilterProvider.tsx | 20 +- .../src/client/filter/utils.ts | 38 +++ .../src/client/hooks/filter.ts | 259 +++++++++++------- .../src/client/hooks/query.ts | 180 ++++++++++-- .../src/client/renderer/ChartRenderer.tsx | 14 +- .../client/renderer/ChartRendererProvider.tsx | 20 +- .../src/server/__tests__/api.test.ts | 2 + .../src/server/__tests__/query.test.ts | 9 +- .../src/server/actions/query.ts | 30 +- 25 files changed, 647 insertions(+), 349 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx diff --git a/packages/core/client/src/data-source/data-source/DataSource.ts b/packages/core/client/src/data-source/data-source/DataSource.ts index d1c5cfa7a..6a991f455 100644 --- a/packages/core/client/src/data-source/data-source/DataSource.ts +++ b/packages/core/client/src/data-source/data-source/DataSource.ts @@ -11,6 +11,7 @@ export interface DataSourceOptions { collections?: CollectionOptions[]; errorMessage?: string; status?: 'loaded' | 'loading-failed' | 'loading' | 'reloading'; + isDBInstance?: boolean; } export type DataSourceFactory = new (options: DataSourceOptions, dataSourceManager: DataSourceManager) => DataSource; diff --git a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx index 0b3b963d0..70400da02 100644 --- a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx +++ b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx @@ -56,13 +56,22 @@ export interface GeneralSchemaDesignerProps { * @default true */ draggable?: boolean; + showDataSource?: boolean; } /** * @deprecated use `SchemaToolbar` instead */ export const GeneralSchemaDesigner: FC = (props: any) => { - const { disableInitializer, title, template, schemaSettings, contextValue, draggable = true } = props; + const { + disableInitializer, + title, + template, + schemaSettings, + contextValue, + draggable = true, + showDataSource = true, + } = props; const { dn, designable } = useDesignable(); const field = useField(); const { t } = useTranslation(); @@ -112,7 +121,9 @@ export const GeneralSchemaDesigner: FC = (props: any
- {dataSource ? `${compile(dataSource?.displayName)} > ${compile(title)}` : compile(title)} + {showDataSource && dataSource + ? `${compile(dataSource?.displayName)} > ${compile(title)}` + : compile(title)} {template && ( diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts index 38e0e716f..336f3fd25 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts @@ -15,66 +15,72 @@ import { describe('hooks', () => { beforeEach(() => { - vi.spyOn(client, 'useCollectionManager_deprecated').mockReturnValue({ - getCollectionFields: (name: string) => - ({ - orders: [ - { - interface: 'string', - name: 'name', - uiSchema: { - title: '{{t("Name")}}', - }, - type: 'string', - }, - { - interface: 'number', - name: 'price', - uiSchema: { - title: '{{t("Price")}}', - }, - type: 'double', - }, - { - interface: 'createdAt', - name: 'createdAt', - uiSchema: { - title: '{{t("Created At")}}', - }, - type: 'date', - }, - { - interface: 'm2o', - name: 'user', - uiSchema: { - title: '{{t("User")}}', - }, - target: 'users', - type: 'belongsTo', - }, - ], - users: [ - { - interface: 'string', - name: 'name', - uiSchema: { - title: '{{t("Name")}}', - }, - type: 'string', - }, - ], - })[name], - getInterface: (i: string) => { - switch (i) { - case 'm2o': - return { - filterable: { - nested: true, - }, - }; - default: - return {}; - } + vi.spyOn(client, 'useDataSourceManager').mockReturnValue({ + getDataSource: () => ({ + collectionManager: { + getCollectionFields: (name: string) => + ({ + orders: [ + { + interface: 'string', + name: 'name', + uiSchema: { + title: '{{t("Name")}}', + }, + type: 'string', + }, + { + interface: 'number', + name: 'price', + uiSchema: { + title: '{{t("Price")}}', + }, + type: 'double', + }, + { + interface: 'createdAt', + name: 'createdAt', + uiSchema: { + title: '{{t("Created At")}}', + }, + type: 'date', + }, + { + interface: 'm2o', + name: 'user', + uiSchema: { + title: '{{t("User")}}', + }, + target: 'users', + type: 'belongsTo', + }, + ], + users: [ + { + interface: 'string', + name: 'name', + uiSchema: { + title: '{{t("Name")}}', + }, + type: 'string', + }, + ], + })[name], + }, + }), + collectionFieldInterfaceManager: { + getFieldInterface: (i: string) => { + switch (i) { + case 'm2o': + return { + filterable: { + nested: true, + }, + }; + default: + return {}; + } + }, }, } as any); }); @@ -84,7 +90,7 @@ describe('hooks', () => { }); test('useFieldsWithAssociation', () => { - const { result } = renderHook(() => useFieldsWithAssociation('orders')); + const { result } = renderHook(() => useFieldsWithAssociation('main', 'orders')); expect(result.current).toMatchObject([ { key: 'name', @@ -118,7 +124,7 @@ describe('hooks', () => { }); test('useChartFields', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useChartFields(fields)); const func = result.current; const field = { @@ -155,7 +161,7 @@ describe('hooks', () => { }); test('useFormatters', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useFormatters(fields)); const func = result.current; const field = { @@ -169,7 +175,7 @@ describe('hooks', () => { }); test('useFieldTypes', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useFieldTypes(fields)); const func = result.current; let state1 = {}; @@ -234,7 +240,7 @@ describe('hooks', () => { }); test('useOrderFieldsOptions', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useOrderFieldsOptions([], fields)); const func = result.current; const field1 = { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx index 5075f340d..90697dbd4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx @@ -10,7 +10,7 @@ import { useChartsTranslation } from '../locale'; export const ChartV2BlockDesigner: React.FC = () => { const { t } = useChartsTranslation(); return ( - + { const { setVisible, setCurrent } = useContext(ChartConfigContext); - const { allowAll, parseAction } = useACLRoleContext(); + const { parseAction } = useACLRoleContext(); const itemConfig = useSchemaInitializerItem(); const filter = useCallback( (item) => { const params = parseAction(`${item.name}:list`); return params; }, - [allowAll, parseAction], + [parseAction], ); return ( { + return ds.key === DEFAULT_DATA_SOURCE_KEY || ds.getOptions().isDBInstance; + }} icon={} componentType={'Chart'} onCreateBlockSchema={async ({ item }) => { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx index 84b6665bc..aba624d9b 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, useState } from 'react'; import { useMemoizedFn } from 'ahooks'; type ChartData = { + dataSource: string; collection: string; service: any; query: any; @@ -18,8 +19,8 @@ export const ChartDataProvider: React.FC = (props) => { const [charts, setCharts] = useState<{ [uid: string]: ChartData; }>({}); - const addChart = useMemoizedFn((uid: string, { collection, service, query }: ChartData) => { - setCharts((charts) => ({ ...charts, [uid]: { collection, service, query } })); + const addChart = useMemoizedFn((uid: string, { dataSource, collection, service, query }: ChartData) => { + setCharts((charts) => ({ ...charts, [uid]: { dataSource, collection, service, query } })); }); const removeChart = useMemoizedFn((uid: string) => { setCharts((charts) => ({ ...charts, [uid]: undefined })); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts index 6248bd6e9..ba0763916 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts @@ -34,14 +34,14 @@ export class Table extends AntdChart { }); const pageSize = advanced?.pagination?.pageSize || 10; return { - bordered: true, + // bordered: true, size: 'middle', - // pagination: - // dataSource.length < pageSize - // ? false - // : { - // pageSize, - // }, + pagination: + dataSource.length < pageSize + ? false + : { + pageSize, + }, dataSource, columns, scroll: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts index d9a041cad..e4efec4bb 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts @@ -35,11 +35,11 @@ export class G2PlotChart extends Chart { }, }, tooltip: (d, index: number, data, column: any) => { - const field = column.y.field; + const field = column.y?.field; const props = fieldProps[field]; const name = props?.label || field; const transformer = props?.transformer; - const value = column.y.value[index]; + const value = column.y?.value[index]; return { name, value: transformer ? transformer(value) : value }; }, axis: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx index ef5d620e4..f8983f4f0 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx @@ -2,21 +2,15 @@ import { RightSquareOutlined } from '@ant-design/icons'; import { ArrayItems, Editable, FormCollapse, FormItem, FormLayout, Switch } from '@formily/antd-v5'; import { Form as FormType, ObjectField, createForm, onFieldChange, onFormInit } from '@formily/core'; import { FormConsumer, ISchema, Schema } from '@formily/react'; -import { - AutoComplete, - FormProvider, - SchemaComponent, - gridRowColWrap, - useCollectionFieldsOptions, - useCollectionFilterOptions, - useDesignable, -} from '@nocobase/client'; +import { AutoComplete, FormProvider, SchemaComponent, gridRowColWrap, useDesignable } from '@nocobase/client'; import { Alert, App, Button, Card, Col, Modal, Row, Space, Table, Tabs, Typography } from 'antd'; import { cloneDeep, isEqual } from 'lodash'; import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useChartFields, useCollectionOptions, + useCollectionFieldsOptions, + useCollectionFilterOptions, useData, useFieldTypes, useFieldsWithAssociation, @@ -57,13 +51,13 @@ export const ChartConfigure: React.FC<{ const { t } = useChartsTranslation(); const { service } = useContext(ChartRendererContext); const { visible, setVisible, current } = useContext(ChartConfigContext); - const { schema, field, collection, initialValues } = current || {}; + const { schema, field, dataSource, collection, initialValues } = current || {}; const { dn } = useDesignable(); const { modal } = App.useApp(); const { insert } = props; const charts = useCharts(); - const fields = useFieldsWithAssociation(collection); + const fields = useFieldsWithAssociation(dataSource, collection); const initChart = (overwrite = false) => { if (!form.modified) { return; @@ -108,15 +102,19 @@ export const ChartConfigure: React.FC<{ const form = useMemo( () => createForm({ - values: { config: { chartType }, ...(initialValues || field?.decoratorProps), collection }, + values: { + config: { chartType }, + ...(initialValues || field?.decoratorProps), + collection: [dataSource, collection], + }, effects: (form) => { onFieldChange('config.chartType', () => initChart(true)); onFormInit(() => queryReact(form)); }, }), - // visible, collection added here to re-initialize form when visible, collection change + // visible, dataSource, collection added here to re-initialize form when visible, dataSource, collection change // eslint-disable-next-line react-hooks/exhaustive-deps - [field, visible, collection], + [field, visible, dataSource, collection], ); const RunButton: React.FC = () => ( @@ -133,7 +131,7 @@ export const ChartConfigure: React.FC<{ } try { - await service.runAsync(collection, form.values.query, true); + await service.runAsync(dataSource, collection, form.values.query, true); } catch (e) { console.log(e); } @@ -163,7 +161,7 @@ export const ChartConfigure: React.FC<{ const { query, config, transform, mode } = form.values; const afterSave = () => { setVisible(false); - current.service?.run(collection, query); + current.service?.run(dataSource, collection, query); queryRef.current.scrollTop = 0; configRef.current.scrollTop = 0; service.mutate(undefined); @@ -171,6 +169,7 @@ export const ChartConfigure: React.FC<{ const rendererProps = { query, config, + dataSource, collection, transform, mode: mode || 'builder', @@ -308,19 +307,20 @@ ChartConfigure.Query = function Query() { const useFormatterOptions = useFormatters(fields); const collectionOptions = useCollectionOptions(); const { current, setCurrent } = useContext(ChartConfigContext); - const { collection } = current || {}; - const fieldOptions = useCollectionFieldsOptions(collection, 1); + const { dataSource, collection } = current || {}; + const fieldOptions = useCollectionFieldsOptions(dataSource, collection, 1); const compiledFieldOptions = Schema.compile(fieldOptions, { t }); - const filterOptions = useCollectionFilterOptions(collection); + const filterOptions = useCollectionFilterOptions(dataSource, collection); const { service } = useContext(ChartRendererContext); - const onCollectionChange = (value: string) => { + const onCollectionChange = (value: string[]) => { const { schema, field } = current; + const [dataSource, collection] = value; setCurrent({ schema, field, - collection: value, - dataSource: current.dataSource, + collection, + dataSource, service: current.service, initialValues: {}, data: undefined, diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts index 523a241d2..d4217418c 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts @@ -150,9 +150,9 @@ export const querySchema: ISchema = { title: '{{t("Collection")}}', type: 'string', 'x-decorator': 'FormItem', - 'x-component': 'Select', + 'x-component': 'Cascader', + enum: '{{ collectionOptions }}', 'x-component-props': { - options: '{{ collectionOptions }}', onChange: '{{ onCollectionChange }}', placeholder: '{{t("Collection")}}', }, diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx new file mode 100644 index 000000000..5d8c33c9a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { InitializerWithSwitch, useSchemaInitializerItem } from '@nocobase/client'; +import { ISchema } from '@formily/react'; + +export const CollectionFieldInitializer = () => { + const schema: ISchema = {}; + const itemConfig = useSchemaInitializerItem(); + return ; +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx index da65c191c..3df5f6582 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx @@ -5,7 +5,7 @@ import { useChartsTranslation } from '../locale'; export const ChartFilterBlockDesigner: React.FC = () => { const { t } = useChartsTranslation(); return ( - + {/* */} {/* */} { const { t } = useChartsTranslation(); @@ -43,6 +44,7 @@ export const ChartFilterBlockProvider: React.FC = (props) => { ArrayItems, ChartFilterCollapseDesigner, ChartFilterActionDesigner, + CollectionFieldInitializer, }} scope={{ t, useChartFilterActionProps, useChartFilterResetProps, useChartFilterCollapseProps }} > diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx index 5b40435a5..b7fbd24e4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx @@ -1,7 +1,7 @@ import React, { memo, useContext, useEffect, useMemo, useRef } from 'react'; import { createForm, onFieldInit, onFieldMount, onFieldUnmount } from '@formily/core'; import { ChartFilterContext } from './FilterProvider'; -import { FormV2, VariablesContext } from '@nocobase/client'; +import { DEFAULT_DATA_SOURCE_KEY, FormV2, VariablesContext } from '@nocobase/client'; import { setDefaultValue } from './utils'; import { useChartFilter } from '../hooks'; @@ -33,7 +33,10 @@ export const ChartFilterForm: React.FC = memo((props) => { if (!name) { return; } - setField(name, { title: field.title, operator: field.componentProps['filter-operator'] }); + setField(name, { + title: field.title, + operator: field.componentProps['filter-operator'], + }); // parse field title if (field.title.includes('/')) { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx index 9fa3fbc21..4e29baa32 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx @@ -13,6 +13,7 @@ import { useDesignable, SchemaSettingsSelectItem, CollectionFieldOptions_deprecated, + DEFAULT_DATA_SOURCE_KEY, } from '@nocobase/client'; import { useChartsTranslation } from '../locale'; import { Schema, useField, useFieldSchema } from '@formily/react'; @@ -21,7 +22,7 @@ import _ from 'lodash'; import { ChartFilterContext } from './FilterProvider'; import { getPropsSchemaByComponent, setDefaultValue } from './utils'; import { ChartFilterVariableInput } from './FilterVariableInput'; -import { useChartFilter, useCollectionJoinFieldTitle } from '../hooks'; +import { useChartDataSource, useChartFilter, useCollectionJoinFieldTitle } from '../hooks'; import { Typography } from 'antd'; import { getFormulaInterface } from '../utils'; const { Text } = Typography; @@ -72,29 +73,33 @@ const EditTitle = () => { const EditOperator = () => { const compile = useCompile(); const fieldSchema = useFieldSchema(); - const fieldName = fieldSchema.name as string; const field = useField(); const { t } = useChartsTranslation(); const { dn } = useDesignable(); const { setField } = useContext(ChartFilterContext); - const { getInterface, getCollectionJoinField } = useCollectionManager_deprecated(); + const fieldName = fieldSchema['x-collection-field']; + const dataSource = fieldSchema['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; + const { cm, fim } = useChartDataSource(dataSource); + if (!cm) { + return null; + } const getOperators = (props: CollectionFieldOptions_deprecated) => { let fieldInterface = props?.interface; if (fieldInterface === 'formula') { fieldInterface = getFormulaInterface(props.dataType) || props.dataType; } - const interfaceConfig = getInterface(fieldInterface); + const interfaceConfig = fim.getFieldInterface(fieldInterface); const operatorList = interfaceConfig?.filterable?.operators || []; return { operatorList, interfaceConfig }; }; - let props = getCollectionJoinField(fieldName); + let props = cm.getCollectionField(fieldName); let { operatorList, interfaceConfig } = getOperators(props); if (!operatorList.length) { const names = fieldName.split('.'); const name = names.pop(); - props = getCollectionJoinField(names.join('.')); + props = cm.getCollectionField(names.join('.')); if (!props) { return null; } @@ -159,7 +164,7 @@ const EditOperator = () => { setOperatorComponent(operator, defaultComponent); } - setField(fieldName, { operator }); + setField(fieldSchema.name as string, { operator }); dn.refresh(); }} /> @@ -300,10 +305,11 @@ export const ChartFilterItemDesigner: React.FC = () => { const { t } = useChartsTranslation(); const fieldSchema = useFieldSchema(); const fieldName = fieldSchema.name as string; + const dataSource = fieldSchema['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; const collectionField = getField(fieldName) || getCollectionJoinField(fieldSchema['x-collection-field']); const isCustom = fieldName.startsWith('custom.'); const hasProps = getPropsSchemaByComponent(fieldSchema['x-component']); - const originalTitle = useCollectionJoinFieldTitle(fieldName); + const originalTitle = useCollectionJoinFieldTitle(dataSource, fieldName); return ( {!isCustom && ( diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx index 6fa6c3fc8..9534a7558 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx @@ -7,7 +7,9 @@ import { ACLCollectionFieldProvider, BlockItem, CollectionFieldProvider, + CollectionManagerProvider, CollectionProvider, + DEFAULT_DATA_SOURCE_KEY, CompatibleSchemaInitializer, FormDialog, HTMLEncode, @@ -16,6 +18,7 @@ import { SchemaInitializerItem, gridRowColWrap, useCollectionManager_deprecated, + useDataSourceManager, useDesignable, useGlobalTheme, useSchemaInitializerItem, @@ -50,6 +53,7 @@ const ErrorFallback = ({ error }) => { export const ChartFilterFormItem = observer( (props: any) => { + const { t } = useChartsTranslation(); const field = useField(); const schema = useFieldSchema(); const showTitle = schema['x-decorator-props']?.showTitle ?? true; @@ -80,21 +84,31 @@ export const ChartFilterFormItem = observer( }, ); }, [showTitle]); + const dataSource = schema?.['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; const collectionField = schema?.['x-collection-field'] || ''; const [collection] = collectionField.split('.'); - + // const { getIsChartCollectionExists } = useChartData(); + // const exists = (schema.name as string).startsWith('custom.') || getIsChartCollectionExists(dataSource, collection); return ( - - - - - console.log(err)} FallbackComponent={ErrorFallback}> - - - - - - + + + + + + {/* {exists ? ( */} + console.log(err)} FallbackComponent={ErrorFallback}> + + + {/* ) : ( */} + {/*
*/} + {/* {t('The chart using the collection of this field have been deleted. Please remove this field.')} */} + {/*
*/} + {/* )} */} +
+
+
+
+
); }, { displayName: 'ChartFilterFormItem' }, @@ -110,7 +124,7 @@ export const ChartFilterCustomItemInitializer: React.FC<{ const { theme } = useGlobalTheme(); const { insert } = props; const itemConfig = useSchemaInitializerItem(); - const { getCollectionJoinField, getInterface } = useCollectionManager_deprecated(); + const dm = useDataSourceManager(); const sourceFields = useChartFilterSourceFields(); const { options: fieldComponents, values: fieldComponentValues } = useFieldComponents(); const handleClick = useCallback(async () => { @@ -177,8 +191,17 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }, effects() { onFieldValueChange('source', (field) => { - const name = field.value?.join('.'); - const props = getCollectionJoinField(name); + if (!field.value) { + return; + } + const [dataSource, ...fields] = field.value; + const ds = dm.getDataSource(dataSource); + if (!ds) { + return; + } + const cm = ds.collectionManager; + const name = fields.join('.'); + const props = cm.getCollectionField(name); if (!props) { return; } @@ -204,7 +227,8 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }, }); const { name, title, component, props } = values; - const defaultSchema = getInterface(component)?.default?.uiSchema || {}; + const fim = dm.collectionFieldInterfaceManager; + const defaultSchema = fim.getFieldInterface(component)?.default?.uiSchema || {}; insert( gridRowColWrap({ 'x-component': component, @@ -227,11 +251,8 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }); ChartFilterCustomItemInitializer.displayName = 'ChartFilterCustomItemInitializer'; -/** - * @deprecated - */ -export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitializer({ - name: 'ChartFilterItemInitializers', +const filterItemInitializers = { + name: 'chartFilterForm:configureFields', 'data-testid': 'configure-fields-button-of-chart-filter-item', wrap: gridRowColWrap, icon: 'SettingOutlined', @@ -242,23 +263,34 @@ export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitia name: 'displayFields', title: '{{ t("Display fields") }}', useChildren: () => { - const { getCollection } = useCollectionManager_deprecated(); - const { getChartCollections } = useChartData(); + const { t } = useChartsTranslation(); + const { chartCollections, showDataSource } = useChartData(); const { getChartFilterFields } = useChartFilter(); - const collections = getChartCollections(); + const dm = useDataSourceManager(); + const fim = dm.collectionFieldInterfaceManager; return useMemo(() => { - return collections.map((name: any) => { - const collection = getCollection(name); - const fields = getChartFilterFields(collection); + const options = Object.entries(chartCollections).map(([dataSource, collections]) => { + const ds = dm.getDataSource(dataSource); return { - name: collection.key, + name: ds.key, + title: Schema.compile(ds.displayName, { t }), type: 'subMenu', - title: collection.title, - children: fields, + children: collections.map((name) => { + const cm = ds.collectionManager; + const collection = cm.getCollection(name); + const fields = getChartFilterFields({ dataSource, collection, cm, fim }); + return { + name: collection.key, + title: Schema.compile(collection.title, { t }), + type: 'subMenu', + children: fields, + }; + }), }; }); - }, [collections]); + return showDataSource ? options : options[0]?.children || []; + }, [chartCollections, showDataSource]); }, }, { @@ -275,54 +307,17 @@ export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitia }, }, ], +}; + +/** + * @deprecated + */ +export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitializer({ + ...filterItemInitializers, + name: 'ChartFilterItemInitializers', }); export const chartFilterItemInitializers = new CompatibleSchemaInitializer( - { - name: 'chartFilterForm:configureFields', - 'data-testid': 'configure-fields-button-of-chart-filter-item', - wrap: gridRowColWrap, - icon: 'SettingOutlined', - title: '{{ t("Configure fields") }}', - items: [ - { - type: 'itemGroup', - name: 'displayFields', - title: '{{ t("Display fields") }}', - useChildren: () => { - const { getCollection } = useCollectionManager_deprecated(); - const { getChartCollections } = useChartData(); - const { getChartFilterFields } = useChartFilter(); - const collections = getChartCollections(); - - return useMemo(() => { - return collections.map((name: any) => { - const collection = getCollection(name); - const fields = getChartFilterFields(collection); - return { - name: collection.key, - type: 'subMenu', - title: collection.title, - children: fields, - }; - }); - }, [collections]); - }, - }, - { - name: 'divider', - type: 'divider', - }, - { - name: 'custom', - type: 'item', - title: lang('Custom'), - Component: () => { - const { insertAdjacent } = useDesignable(); - return insertAdjacent('beforeEnd', s)} />; - }, - }, - ], - }, + filterItemInitializers, chartFilterItemInitializers_deprecated, ); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx index f4d602bd3..cbf6e2948 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx @@ -1,20 +1,22 @@ import React, { createContext, useEffect, useState } from 'react'; import { useMemoizedFn } from 'ahooks'; +type FilterField = { + title?: string; + operator?: { + value: string; + noValue?: boolean; + }; +}; + export const ChartFilterContext = createContext<{ ready: boolean; enabled: boolean; setEnabled: (enabled: boolean) => void; fields: { - [name: string]: { - title: string; - operator?: { - value: string; - noValue?: boolean; - }; - }; + [name: string]: FilterField; }; - setField: (name: string, field: { title?: string; operator?: string }) => void; + setField: (name: string, field: FilterField) => void; removeField: (name: string) => void; collapse: { collapsed: boolean; @@ -32,7 +34,7 @@ export const ChartFilterProvider: React.FC = (props) => { const [fields, setFields] = useState({}); const [collapse, _setCollapse] = useState({ collapsed: false, row: 1 }); const [form, _setForm] = useState(); - const setField = useMemoizedFn((name: string, props: { title?: string; operator?: string }) => { + const setField = useMemoizedFn((name: string, props: FilterField) => { setFields((fields) => ({ ...fields, [name]: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts index 2e3f02e11..d018de38a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts @@ -1,5 +1,7 @@ +import { DEFAULT_DATA_SOURCE_KEY } from '@nocobase/client'; import { moment2str } from '@nocobase/utils/client'; import dayjs from 'dayjs'; +import { Schema } from '@formily/react'; export const getOptionsSchema = () => { const options = { @@ -175,3 +177,39 @@ export const setDefaultValue = async (field: any, variables: any) => { field.loading = false; } }; + +export const FILTER_FIELD_PREFIX_SEPARATOR = '-'; + +export const getFilterFieldPrefix = (dataSource: string, fieldName: string) => { + return dataSource ? `${dataSource}${FILTER_FIELD_PREFIX_SEPARATOR}${fieldName}` : fieldName; +}; + +// [dataSource-]collection.fieldName.associateName +export const parseFilterFieldName = (name: string) => { + const [prefix, fieldName] = name.split(FILTER_FIELD_PREFIX_SEPARATOR); + if (fieldName) { + return { dataSource: prefix, fieldName }; + } + return { dataSource: DEFAULT_DATA_SOURCE_KEY, fieldName: prefix }; +}; + +export const findSchema = (schema: Schema, key: string, targetName: string) => { + if (!Schema.isSchemaInstance(schema)) return null; + return schema.reduceProperties((buf, s) => { + let fieldName = s[key]; + if (!fieldName.includes(FILTER_FIELD_PREFIX_SEPARATOR)) { + fieldName = `${DEFAULT_DATA_SOURCE_KEY}${FILTER_FIELD_PREFIX_SEPARATOR}${fieldName}`; + } + if (fieldName === targetName) { + return s; + } + if (s['x-component'] !== 'Action.Container' && s['x-component'] !== 'AssociationField.Viewer') { + const c = findSchema(s, key, targetName); + if (c) { + return c; + } + } + + return buf; + }); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts index fc0b8993f..e5f616840 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts @@ -1,7 +1,16 @@ -import { SchemaInitializerItemType, i18n, useActionContext, useCollectionManager_deprecated } from '@nocobase/client'; -import { useContext, useMemo } from 'react'; +import { + Collection, + CollectionFieldInterfaceManager, + CollectionFieldOptions, + CollectionManager, + SchemaInitializerItemType, + i18n, + useActionContext, + useCollectionManager_deprecated, + useDataSourceManager, +} from '@nocobase/client'; +import { useCallback, useContext, useMemo } from 'react'; import { ChartDataContext } from '../block/ChartDataProvider'; -import { CollectionOptions } from '@nocobase/database'; import { Schema } from '@formily/react'; import { useChartsTranslation } from '../locale'; import { ChartFilterContext } from '../filter/FilterProvider'; @@ -10,6 +19,7 @@ import { parse } from '@nocobase/utils/client'; import lodash from 'lodash'; import { getFormulaComponent, getValuesByPath } from '../utils'; import deepmerge from 'deepmerge'; +import { findSchema, getFilterFieldPrefix, parseFilterFieldName } from '../filter/utils'; export const useCustomFieldInterface = () => { const { getInterface } = useCollectionManager_deprecated(); @@ -44,35 +54,63 @@ export const useCustomFieldInterface = () => { export const useChartData = () => { const { charts } = useContext(ChartDataContext); - const getChartCollections = () => - Array.from( - new Set( - Object.values(charts) - .filter((chart) => chart) - .map((chart) => chart.collection), - ), - ); + const chartCollections: { + [dataSource: string]: string[]; + } = useMemo(() => { + return Object.values(charts) + .filter((chart) => chart) + .reduce((mp, chart) => { + const { dataSource, collection } = chart; + if (mp[dataSource]?.includes(collection)) { + return mp; + } + mp[dataSource] = [...(mp[dataSource] || []), collection]; + return mp; + }, {}); + }, [charts]); + + const showDataSource = useMemo(() => { + return Object.keys(chartCollections).length > 1; + }, [chartCollections]); + + const getIsChartCollectionExists = useCallback( + (dataSource: string, collection: string) => { + return chartCollections[dataSource]?.includes(collection) || false; + }, + [chartCollections], + ); return { - getChartCollections, + chartCollections, + showDataSource, + getIsChartCollectionExists, }; }; export const useChartFilter = () => { + const dm = useDataSourceManager(); const { charts } = useContext(ChartDataContext); const { fieldSchema } = useActionContext(); const action = fieldSchema?.['x-action']; - const { getCollection, getInterface, getCollectionFields, getCollectionJoinField } = - useCollectionManager_deprecated(); const { fields: fieldProps, form } = useContext(ChartFilterContext); - const getChartFilterFields = (collection: CollectionOptions) => { - const fields = getCollectionFields(collection); - const field2item = (field: any, title: string, name: string) => { + const getChartFilterFields = ({ + dataSource, + collection, + cm, + fim, + }: { + dataSource: string; + collection: Collection; + cm: CollectionManager; + fim: CollectionFieldInterfaceManager; + }) => { + const fields = cm.getCollectionFields(collection.name); + const field2item = (field: any, title: string, name: string, fieldName: string) => { const fieldTitle = field.uiSchema?.title || field.name; - const interfaceConfig = getInterface(field.interface); + const interfaceConfig = fim.getFieldInterface(field.interface); const defaultOperator = interfaceConfig?.filterable?.operators?.[0]; - const targetCollection = getCollection(field.target); + const targetCollection = cm.getCollection(field.target); title = title ? `${title} / ${fieldTitle}` : fieldTitle; let schema = { type: 'string', @@ -82,7 +120,8 @@ export const useChartFilter = () => { 'x-designer': 'ChartFilterItemDesigner', 'x-component': 'CollectionField', 'x-decorator': 'ChartFilterFormItem', - 'x-collection-field': `${name}.${field.name}`, + 'x-data-source': dataSource, + 'x-collection-field': `${fieldName}.${field.name}`, 'x-component-props': { ...field.uiSchema?.['x-component-props'], 'filter-operator': defaultOperator, @@ -104,6 +143,7 @@ export const useChartFilter = () => { type: 'item', title: field?.uiSchema?.title || field.name, Component: 'CollectionFieldInitializer', + find: findSchema, remove: (schema, cb) => { cb(schema, { breakRemoveOn: { @@ -126,7 +166,7 @@ export const useChartFilter = () => { return resultItem; }; - const children2item = (child: any, title: string, name: string) => { + const children2item = (child: any, title: string, name: string, fieldName: string) => { const childTitle = child.uiSchema?.title || child.name; title = title ? `${title} / ${childTitle}` : childTitle; const defaultOperator = child.operators[0]; @@ -136,7 +176,8 @@ export const useChartFilter = () => { required: false, 'x-designer': 'ChartFilterItemDesigner', 'x-decorator': 'ChartFilterFormItem', - 'x-collection-field': `${name}.${child.name}`, + 'x-data-source': dataSource, + 'x-collection-field': `${fieldName}.${child.name}`, ...child.schema, title, 'x-component-props': { @@ -159,6 +200,7 @@ export const useChartFilter = () => { type: 'item', title: child.title || child.name, Component: 'CollectionFieldInitializer', + find: findSchema, remove: (schema, cb) => { cb(schema, { breakRemoveOn: { @@ -172,23 +214,31 @@ export const useChartFilter = () => { return resultItem; }; - const field2option = (field: any, depth: number, title: string, name: string): SchemaInitializerItemType => { + const field2option = ( + field: any, + depth: number, + title: string, + name: string, + fieldName: string, + ): SchemaInitializerItemType => { if (!field.interface) { return; } - const fieldInterface = getInterface(field.interface); + const fieldInterface = fim.getFieldInterface(field.interface); if (!fieldInterface?.filterable) { return; } const { nested, children } = fieldInterface.filterable; const fieldTitle = field.uiSchema?.title || field.name; - const item = field2item(field, title, name); + const item = field2item(field, title, name, fieldName); if (field.target && depth > 2) { return; } title = title ? `${title} / ${fieldTitle}` : fieldTitle; - if (children?.length && !['chinaRegion', 'createdBy', 'updatedBy'].includes(field.interface)) { - const items = children.map((child: any) => children2item(child, title, `${name}.${field.name}`)); + if (children?.length && !['chinaRegion', 'createdBy', 'updatedBy', 'attachment'].includes(field.interface)) { + const items = children.map((child: any) => + children2item(child, title, `${name}.${field.name}`, `${fieldName}.${field.name}`), + ); return { key: `${name}.${field.name}`, name: field.name, @@ -201,9 +251,9 @@ export const useChartFilter = () => { return item; } if (nested) { - const targetFields = getCollectionFields(field.target); + const targetFields = cm.getCollectionFields(field.target); const items = targetFields.map((targetField) => - field2option(targetField, depth + 1, '', `${name}.${field.name}`), + field2option(targetField, depth + 1, '', `${name}.${field.name}`, `${fieldName}.${field.name}`), ); return { key: `${name}.${field.name}`, @@ -220,12 +270,12 @@ export const useChartFilter = () => { const associationOptions = []; fields.forEach((field) => { const fieldInterface = field.interface; - const option = field2option(field, 0, '', collection.name); + const option = field2option(field, 0, '', getFilterFieldPrefix(dataSource, collection.name), collection.name); if (option) { options.push(option); } if (['m2o'].includes(fieldInterface)) { - const option = field2option(field, 1, '', collection.name); + const option = field2option(field, 1, '', getFilterFieldPrefix(dataSource, collection.name), collection.name); if (option) { associationOptions.push(option); } @@ -251,40 +301,47 @@ export const useChartFilter = () => { const getFilter = () => { const values = form?.values || {}; const filter = {}; - Object.entries(fieldProps).forEach(([name, props]) => { - const { operator } = props || {}; - const field = getCollectionJoinField(name); - if (field?.target) { - name = `${name}.${field.targetKey || 'id'}`; - } - const [collection, ...fields] = name.split('.'); - const value = getValuesByPath(values, name); - const op = operator?.value || '$eq'; - if (collection !== 'custom') { - filter[collection] = filter[collection] || { $and: [] }; - const condition = {}; - lodash.set(condition, fields.join('.'), { [op]: value }); - filter[collection].$and.push(condition); - } else { - filter[collection] = filter[collection] || {}; - filter[collection][`$nFilter.${fields.join('.')}`] = value; - } - }); + Object.entries(fieldProps) + .filter(([_, props]) => props) + .forEach(([name, props]) => { + const { operator } = props || {}; + const { dataSource, fieldName } = parseFilterFieldName(name); + const ds = dm.getDataSource(dataSource); + const cm = ds.collectionManager; + const field = cm.getCollectionField(fieldName); + if (field?.target) { + name = `${fieldName}.${field.targetKey || 'id'}`; + } + const [collection, ...fields] = fieldName.split('.'); + const value = getValuesByPath(values, name); + const op = operator?.value || '$eq'; + if (collection !== 'custom') { + const key = getFilterFieldPrefix(dataSource, collection); + filter[key] = filter[key] || { $and: [] }; + const condition = {}; + lodash.set(condition, fields.join('.'), { [op]: value }); + filter[key].$and.push(condition); + } else { + filter[collection] = filter[collection] || {}; + filter[collection][`$nFilter.${fields.join('.')}`] = value; + } + }); return filter; }; - const hasFilter = (chart: { collection: string; query: any }, filterValues: any) => { - const { collection, query } = chart; + const hasFilter = (chart: { dataSource: string; collection: string; query: any }, filterValues: any) => { + const { dataSource, collection, query } = chart; const { parameters } = parse(query.filter || ''); return ( chart && - (filterValues[collection] || - (filterValues['custom'] && parameters?.find((param: { key: string }) => filterValues['custom'][param.key]))) + (filterValues[getFilterFieldPrefix(dataSource, collection)] || + (filterValues['custom'] && + parameters?.find(({ key }: { key: string }) => lodash.has(filterValues['custom'], key)))) ); }; - const appendFilter = (chart: { collection: string; query: any }, filterValues: any) => { - const { collection, query } = chart; + const appendFilter = (chart: { dataSource: string; collection: string; query: any }, filterValues: any) => { + const { dataSource, collection, query } = chart; let newQuery = { ...query }; const originFilter = { ...(newQuery.filter || {}) }; let filter = {}; @@ -297,7 +354,7 @@ export const useChartFilter = () => { newQuery = { ...newQuery, filter: { - $and: [filter, filterValues[collection]], + $and: [filter, filterValues[getFilterFieldPrefix(dataSource, collection)]], }, }; return newQuery; @@ -308,8 +365,8 @@ export const useChartFilter = () => { const requests = Object.values(charts) .filter((chart) => hasFilter(chart, filterValues)) .map((chart) => async () => { - const { service, collection } = chart; - await service.runAsync(collection, appendFilter(chart, filterValues), true); + const { dataSource, service, collection } = chart; + await service.runAsync(dataSource, collection, appendFilter(chart, filterValues), true); }); await Promise.all(requests.map((request) => request())); }; @@ -320,8 +377,8 @@ export const useChartFilter = () => { return chart; }) .map((chart) => async () => { - const { service, collection, query } = chart; - await service.runAsync(collection, query, true); + const { service, dataSource, collection, query } = chart; + await service.runAsync(dataSource, collection, query, true); }); await Promise.all(requests.map((request) => request())); }; @@ -375,14 +432,16 @@ export const useFilterVariable = () => { export const useChartFilterSourceFields = () => { const { t } = useChartsTranslation(); - const { getChartCollections } = useChartData(); - const { getInterface, getCollectionFields, getCollection } = useCollectionManager_deprecated(); + const { chartCollections } = useChartData(); + const dm = useDataSourceManager(); + const fim = dm.collectionFieldInterfaceManager; + const { values } = useFieldComponents(); - const field2option = (field: any, depth: number) => { + const field2option = (cm: CollectionManager, field: any, depth: number) => { if (!field.interface) { return; } - const fieldInterface = getInterface(field.interface); + const fieldInterface = fim.getFieldInterface(field.interface); if (!fieldInterface?.filterable) { return; } @@ -398,8 +457,8 @@ export const useChartFilterSourceFields = () => { return item; } if (nested) { - const targetFields = getCollectionFields(field.target); - const items = targetFields.map((targetField) => field2option(targetField, depth + 1)); + const targetFields = cm.getCollectionFields(field.target); + const items = targetFields.map((targetField) => field2option(cm, targetField, depth + 1)); return { value: field.name, label: t(field?.uiSchema?.title || field.name), @@ -412,29 +471,28 @@ export const useChartFilterSourceFields = () => { return item; }; - const collections = getChartCollections(); return useMemo(() => { - const options = []; - collections.forEach((name) => { - const collection = getCollection(name); - const children = []; - const fields = getCollectionFields(collection); - fields.forEach((field) => { - const option = field2option(field, 1); - if (option) { - children.push(option); - } - }); - if (children.length) { - options.push({ - value: name, - label: t(collection.title), - children, - }); - } + const options = Object.entries(chartCollections).map(([dataSource, collections]) => { + const ds = dm.getDataSource(dataSource); + return { + value: dataSource, + label: Schema.compile(ds.displayName, { t }), + children: collections.map((name: string) => { + const cm = ds.collectionManager; + const collection = cm.getCollection(name); + const fields = cm.getCollectionFields(name); + const children = fields.map((field) => field2option(cm, field, 1)).filter((item) => item); + return { + value: name, + label: Schema.compile(collection.title, { t }), + children, + }; + }), + }; }); + return options; - }, [collections]); + }, [chartCollections]); }; export const useFieldComponents = () => { @@ -457,26 +515,35 @@ export const useFieldComponents = () => { }; }; -export const useCollectionJoinFieldTitle = (name: string) => { - const { getCollection, getCollectionField } = useCollectionManager_deprecated(); +export const useCollectionJoinFieldTitle = (dataSource: string, name: string) => { + const { t } = useChartsTranslation(); + const dm = useDataSourceManager(); + const { showDataSource } = useChartData(); + return useMemo(() => { + const ds = dm.getDataSource(dataSource); + if (!ds) { + return; + } + const cm = ds.collectionManager; if (!name) { return; } - const [collectionName, ...fieldNames] = name.split('.'); + const { fieldName } = parseFilterFieldName(name); + const [collectionName, ...fieldNames] = fieldName.split('.'); if (!fieldNames?.length) { return; } - const collection = getCollection(collectionName); + const collection = cm.getCollection(collectionName); let cName: any = collectionName; let field: any; - let title = Schema.compile(collection?.title, { t: i18n.t }); + let title = Schema.compile(collection?.title, { t }); while (cName && fieldNames.length > 0) { const fileName = fieldNames.shift(); - field = getCollectionField(`${cName}.${fileName}`); + field = cm.getCollectionField(`${cName}.${fileName}`); const fieldTitle = field?.uiSchema?.title || field?.name; if (fieldTitle) { - title += ` / ${Schema.compile(fieldTitle, { t: i18n.t })}`; + title += ` / ${Schema.compile(fieldTitle, { t })}`; } if (field?.target) { cName = field.target; @@ -484,6 +551,6 @@ export const useCollectionJoinFieldTitle = (name: string) => { cName = null; } } - return title; - }, [name]); + return showDataSource ? `${Schema.compile(ds.displayName, { t })} > ${title}` : title; + }, [name, dataSource, showDataSource]); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts index daf17dee2..7d287ada6 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts @@ -1,9 +1,13 @@ import { ArrayField } from '@formily/core'; import { ISchema, Schema, useForm } from '@formily/react'; import { + CollectionFieldOptions, CollectionFieldOptions_deprecated, + CollectionManager, + DEFAULT_DATA_SOURCE_KEY, useACLRoleContext, useCollectionManager_deprecated, + useDataSourceManager, } from '@nocobase/client'; import { useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,19 +30,24 @@ export type FieldOption = { targetFields?: FieldOption[]; }; +export const useChartDataSource = (dataSource?: string) => { + const { current } = useContext(ChartConfigContext); + const { dataSource: _dataSource = dataSource || DEFAULT_DATA_SOURCE_KEY, collection } = current || {}; + const dm = useDataSourceManager(); + const ds = dm.getDataSource(_dataSource); + const fim = dm.collectionFieldInterfaceManager; + const cm = ds?.collectionManager; + return { cm, fim, collection }; +}; + export const useFields = ( - collection?: string, -): (CollectionFieldOptions_deprecated & { + collectionFields: CollectionFieldOptions[], +): (CollectionFieldOptions & { key: string; label: string; value: string; })[] => { - const { current } = useContext(ChartConfigContext); - if (!collection) { - collection = current?.collection || ''; - } - const { getCollectionFields } = useCollectionManager_deprecated(); - const fields = (getCollectionFields(collection) || []) + const fields = (collectionFields || []) .filter((field) => { return field.interface; }) @@ -51,21 +60,22 @@ export const useFields = ( return fields; }; -export const useFieldsWithAssociation = (collection?: string) => { - const { getCollectionFields, getInterface } = useCollectionManager_deprecated(); +export const useFieldsWithAssociation = (dataSource?: string, collection?: string) => { const { t } = useTranslation(); - const fields = useFields(collection); + const { cm, fim, collection: _collection } = useChartDataSource(dataSource); + const collectionFields = cm.getCollectionFields(collection || _collection); + const fields = useFields(collectionFields); return useMemo( () => fields.map((field) => { - const filterable = getInterface(field.interface)?.filterable; + const filterable = fim.getFieldInterface(field.interface)?.filterable; const label = Schema.compile(field.uiSchema?.title || field.name, { t }); if (!(filterable && (filterable?.nested || filterable?.children?.length))) { return { ...field, label }; } let targetFields = []; if (filterable?.nested) { - const nestedFields = (getCollectionFields(field.target) || []) + const nestedFields = (cm.getCollectionFields(field.target) || []) .filter((targetField) => { return targetField.interface; }) @@ -132,20 +142,23 @@ export const useFormatters = (fields: FieldOption[]) => (field: any) => { export const useCollectionOptions = () => { const { t } = useTranslation(); - const { collections } = useCollectionManager_deprecated(); - const { allowAll, parseAction } = useACLRoleContext(); - const options = collections - .filter((collection: { name: string }) => { - if (allowAll) { - return true; - } + const dm = useDataSourceManager(); + const { parseAction } = useACLRoleContext(); + const allCollections = dm.getAllCollections({ + filterCollection: (collection) => { const params = parseAction(`${collection.name}:list`); return params; - }) - .map((collection: { name: string; title: string }) => ({ - label: collection.title, - value: collection.name, - key: collection.name, + }, + }); + const options = allCollections + .filter(({ key, isDBInstance }) => key === DEFAULT_DATA_SOURCE_KEY || isDBInstance) + .map(({ key, displayName, collections }) => ({ + value: key, + label: displayName, + children: collections.map((collection) => ({ + value: collection.name, + label: collection.title, + })), })); return useMemo(() => Schema.compile(options, { t }), [options]); }; @@ -202,11 +215,124 @@ export const useOrderReaction = (defaultOptions: any[], fields: FieldOption[]) = field.setValue(newOrders); }; -export const useData = (data?: any[], collection?: string) => { +export const useData = (data?: any[], dataSource?: string, collection?: string) => { const { t } = useChartsTranslation(); const { service, query } = useContext(ChartRendererContext); - const fields = useFieldsWithAssociation(collection); + const fields = useFieldsWithAssociation(dataSource, collection); const form = useForm(); const selectedFields = getSelectedFields(fields, form?.values?.query || query); return processData(selectedFields, service?.data || data || [], { t }); }; + +export const useCollectionFieldsOptions = (dataSource: string, collectionName: string, maxDepth = 2, excludes = []) => { + const { cm, fim, collection } = useChartDataSource(dataSource); + const collectionFields = cm.getCollectionFields(collectionName || collection); + const fields = collectionFields.filter((v) => !excludes.includes(v.interface)); + + const field2option = (field, depth, prefix?) => { + if (!field.interface) { + return; + } + const fieldInterface = fim.getFieldInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested, children } = fieldInterface.filterable; + const value = prefix ? `${prefix}.${field.name}` : field.name; + const option = { + ...field, + name: field.name, + title: field?.uiSchema?.title || field.name, + schema: field?.uiSchema, + key: value, + }; + if (field.target && depth > maxDepth) { + return; + } + if (depth > maxDepth) { + return option; + } + if (children?.length) { + option['children'] = children.map((v) => { + return { + ...v, + key: `${field.name}.${v.name}`, + }; + }); + } + if (nested) { + const targetFields = cm.getCollectionFields(field.target).filter((v) => !excludes.includes(v.interface)); + const options = getOptions(targetFields, depth + 1, field.name).filter(Boolean); + option['children'] = option['children'] || []; + option['children'].push(...options); + } + return option; + }; + const getOptions = (fields, depth, prefix?) => { + const options = []; + fields.forEach((field) => { + const option = field2option(field, depth, prefix); + if (option) { + options.push(option); + } + }); + return options; + }; + const options = getOptions(fields, 1); + return options; +}; + +export const useCollectionFilterOptions = (dataSource: string, collection: string) => { + const { cm, fim, collection: _collection } = useChartDataSource(dataSource); + return useMemo(() => { + const fields = cm.getCollectionFields(collection || _collection); + const field2option = (field, depth) => { + if (!field.interface) { + return; + } + const fieldInterface = fim.getFieldInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested, children, operators } = fieldInterface.filterable; + const option = { + name: field.name, + title: field?.uiSchema?.title || field.name, + schema: field?.uiSchema, + operators: + operators?.filter?.((operator) => { + return !operator?.visible || operator.visible(field); + }) || [], + interface: field.interface, + }; + if (field.target && depth > 2) { + return; + } + if (depth > 2) { + return option; + } + if (children?.length) { + option['children'] = children; + } + if (nested) { + const targetFields = cm.getCollectionFields(field.target); + const options = getOptions(targetFields, depth + 1).filter(Boolean); + option['children'] = option['children'] || []; + option['children'].push(...options); + } + return option; + }; + const getOptions = (fields, depth) => { + const options = []; + fields.forEach((field) => { + const option = field2option(field, depth); + if (option) { + options.push(option); + } + }); + return options; + }; + const options = getOptions(fields, 1); + return options; + }, [collection, cm, fim]); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx index c45325e6f..f95dcc296 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx @@ -8,6 +8,7 @@ import { gridRowColWrap, useAPIClient, useCollection_deprecated, + useDataSource, useDesignable, } from '@nocobase/client'; import { Empty, Result, Spin, Typography } from 'antd'; @@ -27,9 +28,9 @@ export const ChartRenderer: React.FC & { } = (props) => { const { t } = useChartsTranslation(); const ctx = useContext(ChartRendererContext); - const { config, transform, collection, service, data: _data } = ctx; - const fields = useFieldsWithAssociation(collection); - const data = useData(_data, collection); + const { config, transform, dataSource, collection, service, data: _data } = ctx; + const fields = useFieldsWithAssociation(dataSource, collection); + const data = useData(_data, dataSource, collection); const general = config?.general || {}; const advanced = config?.advanced || {}; const api = useAPIClient(); @@ -70,7 +71,7 @@ export const ChartRenderer: React.FC & { } if (!(data && data.length)) { - return ; + return ; } return ; @@ -84,14 +85,15 @@ ChartRenderer.Designer = function Designer() { const field = useField(); const schema = useFieldSchema(); const { insertAdjacent } = useDesignable(); - const { name, title, dataSource } = useCollection_deprecated(); + const dataSource = useDataSource(); + const { name, title } = useCollection_deprecated(); return ( { - setCurrent({ schema, field, dataSource, collection: name, service, data: service.data }); + setCurrent({ schema, field, dataSource: dataSource.key, collection: name, service, data: service.data }); setVisible(true); }} > diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx index 9448b187a..cc1046418 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx @@ -1,6 +1,7 @@ import { useFieldSchema } from '@formily/react'; import { CollectionManagerProvider, + DEFAULT_DATA_SOURCE_KEY, MaybeCollectionProvider, useAPIClient, useDataSourceManager, @@ -68,14 +69,14 @@ export const ChartRendererContext = createContext< ChartRendererContext.displayName = 'ChartRendererContext'; export const ChartRendererProvider: React.FC = (props) => { - const { query, config, collection, transform, dataSource } = props; + const { query, config, collection, transform, dataSource = DEFAULT_DATA_SOURCE_KEY } = props; const { addChart } = useContext(ChartDataContext); const { ready, form, enabled } = useContext(ChartFilterContext); const { getFilter, hasFilter, appendFilter } = useChartFilter(); const schema = useFieldSchema(); const api = useAPIClient(); const service = useRequest( - (collection, query, manual) => + (dataSource, collection, query, manual) => new Promise((resolve, reject) => { // Check if the chart is configured if (!(collection && query?.measures?.length)) return resolve(undefined); @@ -83,8 +84,8 @@ export const ChartRendererProvider: React.FC = (props) => { if (enabled && !form) return resolve(undefined); const filterValues = getFilter(); const queryWithFilter = - !manual && hasFilter({ collection, query }, filterValues) - ? appendFilter({ collection, query }, filterValues) + !manual && hasFilter({ dataSource, collection, query }, filterValues) + ? appendFilter({ dataSource, collection, query }, filterValues) : query; api .request({ @@ -92,6 +93,7 @@ export const ChartRendererProvider: React.FC = (props) => { method: 'POST', data: { uid: schema?.['x-uid'], + dataSource, collection, ...queryWithFilter, filter: removeUnparsableFilter(queryWithFilter.filter), @@ -115,14 +117,16 @@ export const ChartRendererProvider: React.FC = (props) => { }) .then((res) => { resolve(res?.data?.data); + }) + .finally(() => { if (!manual && schema?.['x-uid']) { - addChart(schema?.['x-uid'], { collection, service, query }); + addChart(schema?.['x-uid'], { dataSource, collection, service, query }); } }) .catch(reject); }), { - defaultParams: [collection, query], + defaultParams: [dataSource, collection, query], // Wait until ChartFilterProvider is rendered and check the status of the filter form // since the filter parameters should be applied if the filter block is enabled ready: ready && (!enabled || !!form), @@ -131,9 +135,9 @@ export const ChartRendererProvider: React.FC = (props) => { return ( - + - + {props.children} diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts index 223b3aff0..d95b966c1 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts @@ -51,6 +51,7 @@ describe('api', () => { test('query', async () => { const ctx = { + app, db, action: { params: { @@ -82,6 +83,7 @@ describe('api', () => { test('query with sort', async () => { const ctx = { + app, db, action: { params: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts index dc260d0a4..d6419884a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts @@ -1,8 +1,9 @@ -import { MockServer, mockServer } from '@nocobase/test'; +import { MockServer, createMockServer } from '@nocobase/test'; import compose from 'koa-compose'; import { vi } from 'vitest'; import { cacheMiddleware, parseBuilder, parseFieldAndAssociations } from '../actions/query'; const formatter = await import('../actions/formatter'); + describe('query', () => { describe('parseBuilder', () => { const sequelize = { @@ -11,8 +12,10 @@ describe('query', () => { }; let ctx: any; let app: MockServer; - beforeAll(() => { - app = mockServer(); + beforeAll(async () => { + app = await createMockServer({ + plugins: ['data-source-manager'], + }); app.db.options.underscored = true; app.db.collection({ name: 'orders', diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index 42f229128..3c030c278 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -27,6 +27,7 @@ type OrderProps = { type QueryParams = Partial<{ uid: string; + dataSource: string; collection: string; measures: MeasureProps[]; dimensions: DimensionProps[]; @@ -45,6 +46,11 @@ type QueryParams = Partial<{ refresh: boolean; }>; +const getDB = (ctx: Context, dataSource: string) => { + const ds = ctx.app.dataSourceManager.dataSources.get(dataSource); + return ds?.collectionManager.db; +}; + export const postProcess = async (ctx: Context, next: Next) => { const { data, fieldMap } = ctx.action.params.values as { data: any[]; @@ -71,8 +77,9 @@ export const postProcess = async (ctx: Context, next: Next) => { }; export const queryData = async (ctx: Context, next: Next) => { - const { collection, queryParams, fieldMap } = ctx.action.params.values; - const model = ctx.db.getModel(collection); + const { dataSource, collection, queryParams, fieldMap } = ctx.action.params.values; + const db = getDB(ctx, dataSource) || ctx.db; + const model = db.getModel(collection); const data = await model.findAll(queryParams); ctx.action.params.values = { data, @@ -89,8 +96,9 @@ export const queryData = async (ctx: Context, next: Next) => { }; export const parseBuilder = async (ctx: Context, next: Next) => { - const { sequelize } = ctx.db; - const { measures, dimensions, orders, include, where, limit } = ctx.action.params.values; + const { dataSource, measures, dimensions, orders, include, where, limit } = ctx.action.params.values; + const db = getDB(ctx, dataSource) || ctx.db; + const { sequelize } = db; const attributes = []; const group = []; const order = []; @@ -157,8 +165,16 @@ export const parseBuilder = async (ctx: Context, next: Next) => { }; export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { - const { collection: collectionName, measures, dimensions, orders, filter } = ctx.action.params.values as QueryParams; - const collection = ctx.db.getCollection(collectionName); + const { + dataSource, + collection: collectionName, + measures, + dimensions, + orders, + filter, + } = ctx.action.params.values as QueryParams; + const db = getDB(ctx, dataSource) || ctx.db; + const collection = db.getCollection(collectionName); const fields = collection.fields; const models: { [target: string]: { @@ -180,7 +196,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { let fieldType = fields.get(name)?.type; if (target) { const targetField = fields.get(target) as Field; - const targetCollection = ctx.db.getCollection(targetField.target); + const targetCollection = db.getCollection(targetField.target); const targetFields = targetCollection.fields; fieldType = targetFields.get(name)?.type; field = `${target}.${field}`;