diff --git a/packages/core/client/src/index.ts b/packages/core/client/src/index.ts index 04676ddd6..7caadb7f6 100644 --- a/packages/core/client/src/index.ts +++ b/packages/core/client/src/index.ts @@ -46,3 +46,4 @@ export * from './schema-templates'; export * from './style'; export * from './system-settings'; export * from './user'; +export * from './variables'; diff --git a/packages/core/client/src/schema-component/antd/association-field/hooks.ts b/packages/core/client/src/schema-component/antd/association-field/hooks.ts index ba2f705e2..e9946391f 100644 --- a/packages/core/client/src/schema-component/antd/association-field/hooks.ts +++ b/packages/core/client/src/schema-component/antd/association-field/hooks.ts @@ -48,7 +48,7 @@ export function useAssociationFieldContext() { } export default function useServiceOptions(props) { - const { action = 'list', service } = props; + const { action = 'list', service, useOriginalFilter } = props; const fieldSchema = useFieldSchema(); const field = useField(); const { getField } = useCollection(); @@ -119,7 +119,7 @@ export default function useServiceOptions(props) { return mergeFilter( [ mergeFilter([ - isOToAny && !isInFilterFormBlock(fieldSchema) && collectionField?.foreignKey + isOToAny && !isInFilterFormBlock(fieldSchema) && collectionField?.foreignKey && !useOriginalFilter ? { [collectionField.foreignKey]: { $is: null, @@ -132,7 +132,8 @@ export default function useServiceOptions(props) { sourceValue !== undefined && sourceValue !== null && !isInFilterFormBlock(fieldSchema) && - collectionField?.foreignKey + collectionField?.foreignKey && + !useOriginalFilter ? { [collectionField.foreignKey]: { $eq: sourceValue, @@ -149,7 +150,14 @@ export default function useServiceOptions(props) { ], '$or', ); - }, [collectionField?.interface, collectionField?.foreignKey, fieldSchema, fieldServiceFilter, sourceValue]); + }, [ + collectionField?.interface, + collectionField?.foreignKey, + fieldSchema, + fieldServiceFilter, + sourceValue, + useOriginalFilter, + ]); return useMemo(() => { return { diff --git a/packages/core/client/src/schema-component/antd/form-item/index.tsx b/packages/core/client/src/schema-component/antd/form-item/index.tsx index 8a4926b52..cb153889e 100644 --- a/packages/core/client/src/schema-component/antd/form-item/index.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/index.tsx @@ -1,3 +1,4 @@ export * from './FormItem'; export * from './FormItem.Settings'; export * from './FormItem.FilterFormSettings'; +export * from './SchemaSettingOptions'; diff --git a/packages/core/client/src/schema-component/antd/input/index.ts b/packages/core/client/src/schema-component/antd/input/index.ts index 987778bc4..253ba4942 100644 --- a/packages/core/client/src/schema-component/antd/input/index.ts +++ b/packages/core/client/src/schema-component/antd/input/index.ts @@ -2,3 +2,4 @@ export * from './Input'; export * from './ReadPretty'; export * from './Json'; export * from './EllipsisWithTooltip'; +export * from './shared'; diff --git a/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx b/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx index 4f580ea8c..628ae3d37 100644 --- a/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx +++ b/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx @@ -46,7 +46,7 @@ const InternalRemoteSelect = connect( const { getField } = useCollection(); const searchData = useRef(null); const { getCollectionJoinField, getInterface } = useCollectionManager(); - const collectionField = getField(fieldSchema.name); + const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema.name as string); const targetField = _targetField || (collectionField?.target && diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/index.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/index.ts new file mode 100644 index 000000000..8c4b9e99c --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useBaseVariable'; +export * from './useDateVariable'; +export * from './useUserVariable'; diff --git a/packages/core/client/src/schema-settings/VariableInput/index.ts b/packages/core/client/src/schema-settings/VariableInput/index.ts new file mode 100644 index 000000000..ff5267fd6 --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/index.ts @@ -0,0 +1,2 @@ +export * from './hooks'; +export * from './VariableInput'; diff --git a/packages/core/client/src/schema-settings/index.ts b/packages/core/client/src/schema-settings/index.ts index 83823e8b6..c40a3206c 100644 --- a/packages/core/client/src/schema-settings/index.ts +++ b/packages/core/client/src/schema-settings/index.ts @@ -3,3 +3,5 @@ export * from './SchemaSettings'; export * from './hooks/useIsAllowToSetDefaultValue'; export { default as useParseDataScopeFilter } from './hooks/useParseDataScopeFilter'; + +export * from './VariableInput'; diff --git a/packages/core/client/src/variables/index.ts b/packages/core/client/src/variables/index.ts index dc02b4613..bf46a3896 100644 --- a/packages/core/client/src/variables/index.ts +++ b/packages/core/client/src/variables/index.ts @@ -1,4 +1,4 @@ -export { default as VariablesProvider } from './VariablesProvider'; +export { default as VariablesProvider, VariablesContext } from './VariablesProvider'; export { default as useBuiltinVariables } from './hooks/useBuiltinVariables'; export { default as useContextVariable } from './hooks/useContextVariable'; export { default as useLocalVariables } from './hooks/useLocalVariables'; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlock.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlock.tsx index d4f6df147..11f04752e 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlock.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlock.tsx @@ -1,24 +1,27 @@ -import { SchemaInitializerContext, useDesignable, useSchemaInitializer } from '@nocobase/client'; +import { SchemaComponentOptions, SchemaInitializerContext, useSchemaInitializer } from '@nocobase/client'; import React, { useState } from 'react'; -import { ChartConfigContext, ChartConfigCurrent, ChartConfigure } from '../configure/ChartConfigure'; -import { ChartRendererProvider } from '../renderer'; +import { ChartConfigProvider } from '../configure'; +import { ChartDataProvider } from './ChartDataProvider'; +import { ChartRenderer, ChartRendererProvider } from '../renderer'; +import { ChartFilterBlockProvider, ChartFilterBlockDesigner } from '../filter'; +import { ChartFilterProvider } from '../filter/FilterProvider'; export const ChartV2Block: React.FC = (props) => { - const { insertAdjacent } = useDesignable(); - const [visible, setVisible] = useState(false); - const [current, setCurrent] = useState({} as any); const [initialVisible, setInitialVisible] = useState(false); const schemaInitializerContextData = useSchemaInitializer(); return ( - - {props.children} - - insertAdjacent('beforeEnd', schema, options)} /> - - + + + + {props.children} + + + ); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockInitializer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockInitializer.tsx index 98006cb82..bf6cc0bfd 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockInitializer.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockInitializer.tsx @@ -1,38 +1,26 @@ -import { LineChartOutlined } from '@ant-design/icons'; +import { LineChartOutlined, BarChartOutlined } from '@ant-design/icons'; import { uid } from '@formily/shared'; import { - SchemaInitializerItem, + DataBlockInitializer, SchemaInitializer, + SchemaInitializerItem, useACLRoleContext, - useSchemaInitializerMenuItems, useCollectionDataSourceItemsV2, useSchemaInitializer, useSchemaInitializerItem, - useMenuSearch, } from '@nocobase/client'; -import React, { useContext, useState } from 'react'; -import { ChartConfigContext } from '../configure/ChartConfigure'; -import { Menu } from 'antd'; +import React, { useContext } from 'react'; +import { ChartConfigContext } from '../configure'; +import { lang } from '../locale'; +import { FilterBlockInitializer } from '../filter'; -const ConfigureButton = () => { - const itemConfig = useSchemaInitializerItem(); +const ChartInitializer = () => { const { setVisible, setCurrent } = useContext(ChartConfigContext); - return ( - { - setCurrent({ schema: {}, field: null, collection: itemConfig?.name, service: null, data: undefined }); - setVisible(true); - }} - /> - ); -}; - -const ItemsComponent = () => { const collections = useCollectionDataSourceItemsV2('Chart'); const { allowAll, parseAction } = useACLRoleContext(); - const items: any = collections + const itemConfig = useSchemaInitializerItem(); + + const items = collections .filter((item) => { if (allowAll) { return true; @@ -42,17 +30,17 @@ const ItemsComponent = () => { }) .map((item) => ({ ...item, - Component: ConfigureButton, })); - const [isOpenSubMenu, setIsOpenSubMenu] = useState(false); - const searchedChildren = useMenuSearch(items, isOpenSubMenu, true); - const menuItems = useSchemaInitializerMenuItems(searchedChildren); return ( - { - setIsOpenSubMenu(keys.length > 0); + } + componentType={'Chart'} + onCreateBlockSchema={async ({ item }) => { + setCurrent({ schema: {}, field: null, collection: item.name, service: null, data: undefined }); + setVisible(true); }} /> ); @@ -61,11 +49,26 @@ const ItemsComponent = () => { export const chartInitializers = new SchemaInitializer({ name: 'ChartInitializers', icon: 'PlusOutlined', - popoverProps: { - placement: 'bottomLeft', - }, - title: '{{t("Add chart")}}', - ItemsComponent: ItemsComponent, + title: '{{t("Add block")}}', + items: [ + { + name: 'chart', + title: lang('Chart'), + Component: ChartInitializer, + }, + { + name: 'otherBlocks', + type: 'itemGroup', + title: lang('Other blocks'), + children: [ + { + name: 'filter', + title: lang('Filter'), + Component: FilterBlockInitializer, + }, + ], + }, + ], }); export const ChartV2BlockInitializer: React.FC = () => { 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 new file mode 100644 index 000000000..c80b2a218 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useState } from 'react'; +import { useMemoizedFn } from 'ahooks'; + +type ChartData = { + collection: string; + service: any; + query: any; +}; + +export const ChartDataContext = createContext<{ + charts: { [uid: string]: ChartData }; + addChart: (uid: string, chart: ChartData) => void; + removeChart: (uid: string) => void; +}>({} as any); + +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 removeChart = useMemoizedFn((uid: string) => { + setCharts((charts) => ({ ...charts, [uid]: undefined })); + }); + return ( + {props.children} + ); +}; 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 90c89000e..18c62650b 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 @@ -15,9 +15,17 @@ export class Table extends AntdChart { key: item, })) : []; + const rowKey = columns[0]?.dataIndex; const dataSource = data.map((item: any) => { Object.keys(item).map((key: string) => { const props = fieldProps[key]; + if (props?.interface === 'percent') { + const value = Math.round(parseFloat(item[key]) * 100).toFixed(2); + item[key] = `${value}%`; + } + if (typeof item[key] === 'boolean') { + item[key] = item[key].toString(); + } if (props?.transformer) { item[key] = props.transformer(item[key]); } @@ -28,14 +36,18 @@ export class Table extends AntdChart { return { bordered: true, size: 'middle', - pagination: - dataSource.length < pageSize - ? false - : { - pageSize, - }, + // pagination: + // dataSource.length < pageSize + // ? false + // : { + // pageSize, + // }, dataSource, columns, + scroll: { + x: 'max-content', + }, + rowKey, ...general, ...advanced, }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts index 39ae62c89..ddccbab7f 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts @@ -14,6 +14,7 @@ export type RenderProps = { [field: string]: { label: string; transformer: Transformer; + interface: string; }; }; }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigProvider.tsx new file mode 100644 index 000000000..6dba11082 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigProvider.tsx @@ -0,0 +1,48 @@ +import { ISchema } from '@formily/react'; +import React, { createContext, useState } from 'react'; +import { ChartRendererProvider } from '../renderer'; +import { ChartConfigure } from './ChartConfigure'; +import { useDesignable } from '@nocobase/client'; +import { css } from '@emotion/css'; +import { theme } from 'antd'; + +export type ChartConfigCurrent = { + schema: ISchema; + field: any; + collection: string; + service: any; + initialValues?: any; + data: any[]; +}; + +export const ChartConfigContext = createContext<{ + visible: boolean; + setVisible?: (visible: boolean) => void; + current?: ChartConfigCurrent; + setCurrent?: (current: ChartConfigCurrent) => void; +}>({ + visible: true, +}); + +export const ChartConfigProvider: React.FC = (props) => { + const { insertAdjacent } = useDesignable(); + const [visible, setVisible] = useState(false); + const [current, setCurrent] = useState({} as any); + const { token } = theme.useToken(); + return ( + +
+ {props.children} +
+ + insertAdjacent('beforeEnd', schema, options)} /> + +
+ ); +}; 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 bef3d7e7c..39ece3d8c 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 @@ -13,7 +13,7 @@ import { } 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, useMemo, useRef } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useChartFields, useCollectionOptions, @@ -31,32 +31,14 @@ import { createRendererSchema, getField, getSelectedFields } from '../utils'; import { getConfigSchema, querySchema, transformSchema } from './schemas/configure'; import { useChartTypes, useCharts, useDefaultChartType } from '../chart/group'; import { FilterDynamicComponent } from './FilterDynamicComponent'; -import { css } from '@emotion/css'; +import { ChartConfigContext } from './ChartConfigProvider'; const { Paragraph, Text } = Typography; -export type ChartConfigCurrent = { - schema: ISchema; - field: any; - collection: string; - service: any; - initialValues?: any; - data: any[]; -}; - export type SelectedField = { field: string | string[]; alias?: string; }; -export const ChartConfigContext = createContext<{ - visible: boolean; - setVisible?: (visible: boolean) => void; - current?: ChartConfigCurrent; - setCurrent?: (current: ChartConfigCurrent) => void; -}>({ - visible: true, -}); - export const ChartConfigure: React.FC<{ insert: ( s: ISchema, @@ -151,7 +133,7 @@ export const ChartConfigure: React.FC<{ } try { - await service.runAsync(collection, form.values.query); + await service.runAsync(collection, form.values.query, true); } catch (e) { console.log(e); } @@ -164,6 +146,15 @@ export const ChartConfigure: React.FC<{ const queryRef = useRef(null); const configRef = useRef(null); + + useEffect(() => { + if (!visible) { + return; + } + service.run(collection, field?.decoratorProps?.query, 'configure'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + return ( - { - const field = getField(fields, col.split('.')); - return { - title: field?.label || col, - dataIndex: col, - key: col, - }; - })} - size="small" - /> - +
{ + const field = getField(fields, col.split('.')); + return { + title: field?.label || col, + dataIndex: col, + key: col, + }; + })} + size="small" + /> ) : ( { + const { filter } = useChartFilter(); + return { + onClick: filter, + }; +}; + +export const useChartFilterResetProps = () => { + const form = useForm(); + const { filter } = useChartFilter(); + return { + onClick: async () => { + form.reset(); + await filter(); + }, + }; +}; + +export const useChartFilterCollapseProps = () => { + const { + collapse: { collapsed }, + setCollapse, + } = useContext(ChartFilterContext); + + const { t } = useChartsTranslation(); + return { + onClick: () => setCollapse({ collapsed: !collapsed }), + title: ( + <> + {!collapsed ? t('Collapse') : t('Expand')} + + ), + }; +}; + +export const ChartFilterCollapseDesigner: React.FC = (props: any) => { + const { t } = useChartsTranslation(); + return ( + + { + return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar'); + }} + confirm={{ + title: t('Delete action'), + }} + /> + + ); +}; + +export const ChartFilterActionDesigner: React.FC = (props: any) => { + const { buttonEditorProps, ...restProps } = props; + const { t } = useChartsTranslation(); + return ( + + + + { + return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar'); + }} + confirm={{ + title: t('Delete action'), + }} + /> + + ); +}; + +const ChartFilterActionInitializer = (props) => { + const schema = { + title: '{{ t("Filter") }}', + 'x-action': 'submit', + 'x-component': 'Action', + 'x-designer': 'ChartFilterActionDesigner', + 'x-component-props': { + type: 'primary', + useProps: '{{ useChartFilterActionProps }}', + }, + }; + return ; +}; + +const ChartFilterResetInitializer = (props) => { + const schema = { + title: '{{ t("Reset") }}', + 'x-action': 'reset', + 'x-component': 'Action', + 'x-designer': 'ChartFilterActionDesigner', + 'x-component-props': { + useProps: '{{ useChartFilterResetProps }}', + }, + }; + return ; +}; + +const ChartFilterCollapseInitializer = (props) => { + const schema = { + title: `{{ t("Collapse") }}`, + 'x-action': 'collapse', + 'x-component': 'Action', + 'x-component-props': { + type: 'link', + useProps: '{{ useChartFilterCollapseProps }}', + }, + 'x-designer': 'ChartFilterCollapseDesigner', + }; + return ; +}; + +export const chartFilterActionInitializers = new SchemaInitializer({ + name: 'ChartFilterActionInitializers', + 'data-testid': 'configure-actions-button-of-chart-filter', + title: '{{t("Configure actions")}}', + icon: 'SettingOutlined', + items: [ + { + name: 'enbaleActions', + type: 'itemGroup', + title: '{{t("Enable actions")}}', + children: [ + { + name: 'filter', + type: 'item', + title: '{{t("Filter")}}', + component: ChartFilterActionInitializer, + schema: { + 'x-action-settings': {}, + }, + }, + { + name: 'reset', + type: 'item', + title: '{{t("Reset")}}', + component: ChartFilterResetInitializer, + schema: { + 'x-action-settings': {}, + }, + }, + { + name: 'collapse', + type: 'item', + title: '{{t("Collapse")}}', + component: ChartFilterCollapseInitializer, + schema: { + 'x-action-settings': {}, + }, + }, + ], + }, + ], +}); 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 new file mode 100644 index 000000000..da65c191c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx @@ -0,0 +1,18 @@ +import { GeneralSchemaDesigner, SchemaSettingsRemove } from '@nocobase/client'; +import React from 'react'; +import { useChartsTranslation } from '../locale'; + +export const ChartFilterBlockDesigner: React.FC = () => { + const { t } = useChartsTranslation(); + return ( + + {/* */} + {/* */} + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockInitializer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockInitializer.tsx new file mode 100644 index 000000000..f85276afa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockInitializer.tsx @@ -0,0 +1,110 @@ +import React, { useContext } from 'react'; +import { FilterOutlined } from '@ant-design/icons'; +import { + Grid, + gridRowColWrap, + useDesignable, + useCurrentSchema, + SchemaInitializerSwitch, + useSchemaInitializerItem, +} from '@nocobase/client'; +import { uid, merge } from '@formily/shared'; +import { ChartFilterContext } from './FilterProvider'; +import { css } from '@emotion/css'; +import { theme } from 'antd'; + +const createFilterSchema = () => { + return { + type: 'void', + 'x-action': 'filter', + 'x-decorator': 'ChartFilterBlockProvider', + 'x-component': 'CardItem', + 'x-component-props': { + size: 'small', + }, + 'x-designer': 'ChartFilterBlockDesigner', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'ChartFilterForm', + properties: { + grid: { + type: 'void', + 'x-component': 'ChartFilterGrid', + 'x-initializer': 'ChartFilterItemInitializers', + properties: {}, + }, + actions: { + type: 'void', + 'x-initializer': 'ChartFilterActionInitializers', + 'x-component': 'ActionBar', + 'x-component-props': { + layout: 'one-column', + style: { + float: 'right', + marginTop: 8, + }, + }, + properties: {}, + }, + }, + }, + }, + }; +}; + +export const ChartFilterGrid: React.FC = (props) => { + const { + collapse: { collapsed }, + } = useContext(ChartFilterContext); + const { token } = theme.useToken(); + return ( +
+ {props.children} +
+ ); +}; + +export const FilterBlockInitializer: React.FC = () => { + const { insertAdjacent } = useDesignable(); + const { setEnabled } = useContext(ChartFilterContext); + const item = useSchemaInitializerItem(); + const { remove: _remove, disabled } = item; + const type = 'x-action'; + const schema = createFilterSchema(); + const { exists, remove } = useCurrentSchema( + schema?.[type] || item?.schema?.[type], + type, + item.find, + _remove || item.remove, + ); + + return ( + } + checked={exists} + disabled={disabled} + title={item.title} + onClick={() => { + if (disabled) { + return; + } + if (exists) { + setEnabled(false); + return remove(); + } + const s = merge(schema || {}, item.schema || {}); + item?.schemaInitialize?.(s); + insertAdjacent('afterBegin', gridRowColWrap(s)); + setEnabled(true); + }} + /> + ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockProvider.tsx new file mode 100644 index 000000000..256ec4ab6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockProvider.tsx @@ -0,0 +1,53 @@ +import React, { useContext, useEffect } from 'react'; +import { SchemaComponentOptions } from '@nocobase/client'; +import { ChartFilterItemDesigner } from './FilterItemDesigner'; +import { + useChartFilterActionProps, + useChartFilterResetProps, + useChartFilterCollapseProps, + ChartFilterCollapseDesigner, + ChartFilterActionDesigner, +} from './FilterActionInitializers'; +import { ChartFilterGrid } from './FilterBlockInitializer'; +import { useChartsTranslation } from '../locale'; +import { css } from '@emotion/css'; +import { ChartFilterContext } from './FilterProvider'; +import { ChartFilterCheckbox } from './FilterCheckbox'; +import { ArrayItems } from '@formily/antd-v5'; +import { ChartFilterFormItem } from './FilterItemInitializers'; +import { ChartFilterForm } from './FilterForm'; + +export const ChartFilterBlockProvider: React.FC = (props) => { + const { t } = useChartsTranslation(); + const { setEnabled } = useContext(ChartFilterContext); + useEffect(() => { + setEnabled(true); + }, [setEnabled]); + return ( +
+ + {props.children} + +
+ ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterCheckbox.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterCheckbox.tsx new file mode 100644 index 000000000..9d6af5fcf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterCheckbox.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { connect, useField } from '@formily/react'; +import { Checkbox } from 'antd'; +import { Field } from '@formily/core'; + +export const ChartFilterCheckbox = connect((props) => { + const { content } = props; + const field = useField(); + const handleClick = () => { + field.setValue(!field.value); + }; + return ( + + {content} + + ); +}); 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 new file mode 100644 index 000000000..f7fe9acc2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx @@ -0,0 +1,60 @@ +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 { setDefaultValue } from './utils'; +import { useChartFilter } from '../hooks'; + +export const ChartFilterForm: React.FC = memo((props) => { + const { setField, removeField, setForm } = useContext(ChartFilterContext); + const { getTranslatedTitle } = useChartFilter(); + const variables = useRef(null); + variables.current = useContext(VariablesContext); + const form = useMemo( + () => + createForm({ + effects() { + const getField = (field: any) => { + if (field.displayName !== 'Field') { + return null; + } + const { name } = field.props || {}; + return name; + }; + onFieldInit('*', (field: any) => { + const name = getField(field); + if (!name) { + return; + } + field.setValue(null); + }); + onFieldMount('*', async (field: any) => { + const name = getField(field); + if (!name) { + return; + } + setField(name, { title: field.title, operator: field.componentProps['filter-operator'] }); + + // parse field title + if (field.title.includes('/')) { + field.title = getTranslatedTitle(field.title); + } + + // parse default value + setDefaultValue(field, variables.current); + }); + onFieldUnmount('*', (field: any) => { + const name = getField(field); + if (!name) { + return; + } + removeField(name); + }); + }, + }), + [setField, getTranslatedTitle, removeField, variables], + ); + + useEffect(() => setForm(form), [form, setForm]); + return ; +}); 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 new file mode 100644 index 000000000..597a9062c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx @@ -0,0 +1,323 @@ +import React, { useContext } from 'react'; +import { + EditDescription, + GeneralSchemaDesigner, + SchemaSettingsItem, + SchemaSettingsDivider, + SchemaSettingsModalItem, + SchemaSettingsRemove, + VariablesContext, + useCollection, + useCollectionManager, + useCompile, + useDesignable, + SchemaSettingsSelectItem, +} from '@nocobase/client'; +import { useChartsTranslation } from '../locale'; +import { Schema, useField, useFieldSchema } from '@formily/react'; +import { Field } from '@formily/core'; +import _ from 'lodash'; +import { ChartFilterContext } from './FilterProvider'; +import { getPropsSchemaByComponent, setDefaultValue } from './utils'; +import { ChartFilterVariableInput } from './FilterVariableInput'; +import { useChartFilter, useCollectionJoinFieldTitle } from '../hooks'; +import { Typography } from 'antd'; +const { Text } = Typography; + +const EditTitle = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useChartsTranslation(); + const { dn } = useDesignable(); + const { setField } = useContext(ChartFilterContext); + + return ( + { + if (title) { + field.title = title; + fieldSchema.title = title; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + title: fieldSchema.title, + }, + }); + setField(fieldSchema.name as string, { title }); + } + dn.refresh(); + }} + /> + ); +}; + +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(); + let props = getCollectionJoinField(fieldName); + let interfaceConfig = getInterface(props?.interface); + let operatorList = interfaceConfig?.filterable?.operators || []; + if (!operatorList.length) { + const names = fieldName.split('.'); + const name = names.pop(); + props = getCollectionJoinField(names.join('.')); + if (!props) { + return null; + } + interfaceConfig = getInterface(props.interface); + if (!interfaceConfig) { + return null; + } + const children = interfaceConfig?.filterable.children || []; + const child = children.find((item: any) => item.name === name); + operatorList = child?.operators || []; + } + if (!operatorList.length) { + return null; + } + const defaultComponent = interfaceConfig?.default?.uiSchema?.['x-component'] || 'Input'; + const operator = fieldSchema['x-component-props']?.['filter-operator']; + + const setOperatorComponent = (operator: any, component: any, props = {}) => { + const componentProps = field.componentProps || {}; + field.component = component; + field.componentProps = { + ...componentProps, + 'filter-operator': operator, + ...props, + }; + fieldSchema['x-component'] = component; + fieldSchema['x-component-props'] = { + ...fieldSchema['x-component-props'], + 'filter-operator': operator, + ...props, + }; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-component': component, + 'x-component-props': { + ...fieldSchema['x-component-props'], + 'filter-operator': operator, + ...props, + }, + }, + }); + }; + + return ( + { + const operator = operatorList.find((item: any) => item.value === op); + if (operator.noValue) { + setOperatorComponent(operator, 'ChartFilterCheckbox', { + content: Schema.compile(operator.label, { t }), + }); + } else if (operator.schema?.['x-component']) { + setOperatorComponent(operator, operator.schema['x-component']); + } else { + setOperatorComponent(operator, defaultComponent); + } + + setField(fieldName, { operator }); + dn.refresh(); + }} + /> + ); +}; + +const EditProps = () => { + const { t } = useChartsTranslation(); + const { dn } = useDesignable(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const propsSchema = getPropsSchemaByComponent(fieldSchema['x-component']); + return ( + { + field.reset(); + field.componentProps = props; + fieldSchema['x-component-props'] = props; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-component-props': props, + }, + }); + dn.refresh(); + }} + /> + ); +}; + +const EditDefaultValue = () => { + const { t } = useChartsTranslation(); + const { dn } = useDesignable(); + const variables = useContext(VariablesContext); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { getTranslatedTitle } = useChartFilter(); + const title = getTranslatedTitle(fieldSchema.title); + return ( + { + field.setInitialValue(value); + fieldSchema.default = value; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + default: value, + }, + }); + dn.refresh(); + setDefaultValue(field, variables); + }} + /> + ); +}; + +const EditTitleField = () => { + const { getCollectionFields, getCollectionJoinField, getInterface } = useCollectionManager(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useChartsTranslation(); + const { dn } = useDesignable(); + const compile = useCompile(); + const collectionField = getCollectionJoinField(fieldSchema['x-collection-field']); + const targetFields = collectionField?.target + ? getCollectionFields(collectionField?.target) + : getCollectionFields(collectionField?.targetCollection) ?? []; + const options = targetFields + .filter((field) => { + if (field?.target || field.type === 'boolean') { + return false; + } + const fieldInterface = getInterface(field?.interface); + return fieldInterface?.titleUsable; + }) + .map((field) => ({ + value: field?.name, + label: compile(field?.uiSchema?.title) || field?.name, + })); + + return options.length > 0 && fieldSchema['x-component'] === 'CollectionField' ? ( + { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + const fieldNames = { + ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'], + ...field.componentProps.fieldNames, + label, + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['fieldNames'] = fieldNames; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }} + /> + ) : null; +}; + +export const ChartFilterItemDesigner: React.FC = () => { + const { getCollectionJoinField } = useCollectionManager(); + const { getField } = useCollection(); + const { t } = useChartsTranslation(); + const fieldSchema = useFieldSchema(); + const fieldName = fieldSchema.name as string; + const collectionField = getField(fieldName) || getCollectionJoinField(fieldSchema['x-collection-field']); + const isCustom = fieldName.startsWith('custom.'); + const hasProps = getPropsSchemaByComponent(fieldSchema['x-component']); + const originalTitle = useCollectionJoinFieldTitle(fieldName); + return ( + + {!isCustom && ( + <> + + + {t('Original field')}: {originalTitle} + + + + + )} + + + {hasProps && isCustom && } + {!isCustom && } + + + {collectionField ? : null} + + + ); +}; 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 new file mode 100644 index 000000000..e96ff931a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx @@ -0,0 +1,243 @@ +import { + ACLCollectionFieldProvider, + BlockItem, + FormDialog, + HTMLEncode, + SchemaComponent, + SchemaComponentOptions, + SchemaInitializer, + SchemaInitializerItem, + gridRowColWrap, + useCollectionManager, + useDesignable, + useGlobalTheme, + useSchemaInitializerItem, +} from '@nocobase/client'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { lang, useChartsTranslation } from '../locale'; +import { Schema, SchemaOptionsContext, observer, useField, useFieldSchema, useForm } from '@formily/react'; +import { useMemoizedFn } from 'ahooks'; +import { FormLayout, FormItem } from '@formily/antd-v5'; +import { uid } from '@formily/shared'; +import { useChartData, useChartFilter, useChartFilterSourceFields, useFieldComponents } from '../hooks/filter'; +import { Alert } from 'antd'; +import { getPropsSchemaByComponent } from './utils'; +import { Field, onFieldValueChange } from '@formily/core'; +import { css, cx } from '@emotion/css'; + +const FieldComponentProps: React.FC = observer((props) => { + const form = useForm(); + const schema = getPropsSchemaByComponent(form.values.component); + return schema ? : null; +}); + +export const ChartFilterFormItem = observer( + (props: any) => { + const field = useField(); + const schema = useFieldSchema(); + const showTitle = schema['x-decorator-props']?.showTitle ?? true; + const extra = useMemo(() => { + return typeof field.description === 'string' ? ( +
'), + }} + /> + ) : ( + field.description + ); + }, [field.description]); + const className = useMemo(() => { + return cx( + css` + & .ant-space { + flex-wrap: wrap; + } + `, + { + [css` + > .ant-formily-item-label { + display: none; + } + `]: showTitle === false, + }, + ); + }, [showTitle]); + + return ( + + + + + + ); + }, + { displayName: 'ChartFilterFormItem' }, +); + +export const ChartFilterCustomItemInitializer: React.FC<{ + insert?: any; +}> = (props) => { + const { t: lang } = useChartsTranslation(); + const t = useMemoizedFn(lang); + const { scope, components } = useContext(SchemaOptionsContext); + const { theme } = useGlobalTheme(); + const { insert } = props; + const itemConfig = useSchemaInitializerItem(); + const { getCollectionJoinField, getInterface } = useCollectionManager(); + const sourceFields = useChartFilterSourceFields(); + const { options: fieldComponents, values: fieldComponentValues } = useFieldComponents(); + const handleClick = useCallback(async () => { + const values = await FormDialog( + t('Add custom field'), + () => ( + + + + + + + ), + theme, + ).open({ + values: { + name: `f_${uid()}`, + }, + effects() { + onFieldValueChange('source', (field) => { + const name = field.value?.join('.'); + const props = getCollectionJoinField(name); + if (!props) { + return; + } + const uiSchema = props.uiSchema || {}; + let fieldComponent: string; + if (fieldComponentValues.includes(uiSchema['x-component'])) { + fieldComponent = uiSchema['x-component']; + const fieldComponentProps = uiSchema['x-component-props'] || {}; + if (uiSchema.enum) { + fieldComponentProps.options = uiSchema.enum; + } + const componentProps = field.query('.props').take() as Field; + componentProps.setValue(fieldComponentProps); + } else if (fieldComponentValues.includes(props.interface)) { + fieldComponent = props.interface; + } + if (!fieldComponent) { + return; + } + const component = field.query('.component').take() as Field; + component.setValue(fieldComponent); + }); + }, + }); + const { name, title, component, props } = values; + const defaultSchema = getInterface(component)?.default?.uiSchema || {}; + insert( + gridRowColWrap({ + 'x-component': component, + ...defaultSchema, + type: 'string', + title: title, + name: `custom.${name}`, + required: false, + 'x-designer': 'ChartFilterItemDesigner', + 'x-decorator': 'ChartFilterFormItem', + 'x-component-props': { + ...(defaultSchema['x-component-props'] || {}), + ...props, + }, + }), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [theme]); + return ; +}; + +export const chartFilterItemInitializers = new SchemaInitializer({ + name: 'ChartFilterItemInitializers', + '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(); + const { getChartCollections } = useChartData(); + const { getChartFilterFields } = useChartFilter(); + const collections = getChartCollections(); + return collections.map((name: any) => { + const collection = getCollection(name); + const fields = getChartFilterFields(collection); + return { + name: collection.key, + type: 'subMenu', + title: collection.title, + children: fields, + }; + }); + }, + }, + { + name: 'divider', + type: 'divider', + }, + { + name: 'custom', + type: 'item', + title: lang('Custom'), + Component: () => { + const { insertAdjacent } = useDesignable(); + return insertAdjacent('beforeEnd', s)} />; + }, + }, + ], +}); 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 new file mode 100644 index 000000000..e5dfb294b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx @@ -0,0 +1,68 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { useMemoizedFn } from 'ahooks'; + +export const ChartFilterContext = createContext<{ + ready: boolean; + enabled: boolean; + setEnabled: (enabled: boolean) => void; + fields: { + [name: string]: { + title: string; + operator?: { + value: string; + noValue?: boolean; + }; + }; + }; + setField: (name: string, field: { title?: string; operator?: string }) => void; + removeField: (name: string) => void; + collapse: { + collapsed: boolean; + row: number; + }; + setCollapse: (opts: { collapsed?: boolean; row?: number }) => void; + form: any; + setForm: (form: any) => void; +}>({} as any); + +export const ChartFilterProvider: React.FC = (props) => { + const [ready, setReady] = useState(false); + const [enabled, _setEnabled] = useState(false); + 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 }) => { + setFields((fields) => ({ + ...fields, + [name]: { + ...(fields[name] || {}), + ...props, + }, + })); + }); + const removeField = useMemoizedFn((name: string) => { + setFields((fields) => { + const newFields = { + ...fields, + }; + newFields[name] = undefined; + return newFields; + }); + }); + const setForm = useMemoizedFn(_setForm); + const setEnabled = useMemoizedFn(_setEnabled); + const setCollapse = ({ collapsed, row }: { collapsed?: boolean; row?: number }) => { + _setCollapse((collapse) => ({ + collapsed: collapsed !== undefined ? collapsed : collapse.collapsed, + row: row !== undefined ? row : collapse.row, + })); + }; + useEffect(() => setReady(true), []); + return ( + + {props.children} + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterVariableInput.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterVariableInput.tsx new file mode 100644 index 000000000..8d74e5a8e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterVariableInput.tsx @@ -0,0 +1,56 @@ +import { + SchemaComponent, + Variable, + VariableInput, + VariableScopeProvider, + getShouldChange, + useDateVariable, + useUserVariable, +} from '@nocobase/client'; +import React, { useEffect, useMemo } from 'react'; +import { useMemoizedFn } from 'ahooks'; + +export const ChartFilterVariableInput: React.FC = (props) => { + const { value, onChange, fieldSchema } = props; + const userVariable = useUserVariable({ + collectionField: { uiSchema: fieldSchema }, + uiSchema: fieldSchema, + }); + const dateVariable = useDateVariable({ + operator: fieldSchema['x-component-props']?.['filter-operator'], + schema: fieldSchema, + noDisabled: true, + }); + const options = useMemo(() => [userVariable, dateVariable].filter(Boolean), [dateVariable, userVariable]); + const schema = { + ...fieldSchema, + 'x-component': fieldSchema['x-component'] || 'Input', + 'x-decorator': '', + title: '', + name: 'value', + default: '', + }; + const componentProps = fieldSchema['x-component-props'] || {}; + const handleChange = useMemoizedFn(onChange); + useEffect(() => { + if (fieldSchema.default) { + handleChange({ value: fieldSchema.default }); + } + }, [fieldSchema.default, handleChange]); + + return ( + + } + fieldNames={{}} + value={value?.value} + scope={options} + onChange={(v: any) => { + onChange({ value: v }); + }} + shouldChange={getShouldChange({} as any)} + /> + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/index.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/index.ts new file mode 100644 index 000000000..8c04a9d26 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/index.ts @@ -0,0 +1,6 @@ +export * from './FilterBlockDesigner'; +export * from './FilterBlockInitializer'; +export * from './FilterItemInitializers'; +export * from './FilterActionInitializers'; +export * from './FilterItemDesigner'; +export * from './FilterBlockProvider'; 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 new file mode 100644 index 000000000..0e0a2ef35 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts @@ -0,0 +1,177 @@ +import { moment2str } from '@nocobase/utils'; +import dayjs from 'dayjs'; + +export const getOptionsSchema = () => { + const options = { + title: '{{t("Options")}}', + type: 'array', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems', + items: { + type: 'object', + 'x-decorator': 'ArrayItems.Item', + properties: { + space: { + type: 'void', + 'x-component': 'Space', + properties: { + label: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': { + placeholder: '{{t("Option label")}}', + }, + required: true, + }, + value: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': { + placeholder: '{{t("Option value")}}', + }, + required: true, + }, + remove: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.Remove', + }, + }, + }, + }, + }, + properties: { + add: { + type: 'void', + title: 'Add', + 'x-component': 'ArrayItems.Addition', + }, + }, + }; + return options; +}; + +export const getPropsSchemaByComponent = (component: string) => { + const showTime = { + type: 'boolean', + 'x-content': '{{ t("Show time") }}', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + }; + const dateFormat = { + type: 'string', + title: '{{t("Date format")}}', + 'x-component': 'Radio.Group', + 'x-decorator': 'FormItem', + default: 'YYYY-MM-DD', + enum: [ + { + label: '{{t("Year/Month/Day")}}', + value: 'YYYY/MM/DD', + }, + { + label: '{{t("Year-Month-Day")}}', + value: 'YYYY-MM-DD', + }, + { + label: '{{t("Day/Month/Year")}}', + value: 'DD/MM/YYYY', + }, + ], + }; + const options = getOptionsSchema(); + const propsSchema = { + DatePicker: { + type: 'object', + properties: { + dateFormat, + showTime, + }, + }, + 'DatePicker.RangePicker': { + type: 'object', + properties: { + dateFormat, + showTime, + }, + }, + Select: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: [ + { + label: '{{ t("Single select") }}', + value: '', + }, + { + label: '{{ t("Multiple select") }}', + value: 'multiple', + }, + ], + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + 'x-component-props': { + defaultValue: '', + }, + }, + options, + }, + }, + 'Checkbox.Group': { + type: 'object', + properties: { + options, + }, + }, + 'Radio.Group': { + type: 'object', + properties: { + options, + }, + }, + }; + return propsSchema[component]; +}; + +export const transformValue = (value: any, props: any) => { + if (!value) { + return value; + } + if (dayjs.isDayjs(value)) { + if (!props.showTime) { + value = value.startOf('day'); + } + return moment2str(value, props); + } + if (Array.isArray(value) && value.length && dayjs.isDayjs(value[0])) { + let start = value[0]; + let end = value[1]; + if (props.showTime) { + start = start.startOf('day'); + end = end.endOf('day'); + } + return [moment2str(start, props), moment2str(end, props)]; + } + return value; +}; + +export const setDefaultValue = async (field: any, variables: any) => { + const defaultValue = field.initialValue; + const isVariable = + typeof defaultValue === 'string' && defaultValue?.startsWith('{{$') && defaultValue?.endsWith('}}'); + if (!isVariable || !variables) { + field.setValue(defaultValue); + field.setInitialValue(defaultValue); + } else { + field.loading = true; + const value = await variables.parseVariable(defaultValue); + const transformedValue = transformValue(value, field.componentProps); + field.setValue(transformedValue); + field.setInitialValue(transformedValue); + field.loading = false; + } +}; 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 new file mode 100644 index 000000000..5a44c41d1 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts @@ -0,0 +1,485 @@ +import { SchemaInitializerItemType, i18n, useActionContext, useCollectionManager } from '@nocobase/client'; +import { 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'; +import { useMemoizedFn } from 'ahooks'; +import { parse } from '@nocobase/utils'; +import lodash from 'lodash'; +import { getValuesByPath } from '../utils'; +import deepmerge from 'deepmerge'; + +export const useCustomFieldInterface = () => { + const { getInterface } = useCollectionManager(); + return { + getSchemaByInterface: (fieldInterface: string) => { + const interfaceConfig = getInterface(fieldInterface); + const defaultSchema = interfaceConfig?.default.uiSchema; + const schema = { + ...defaultSchema, + }; + switch (fieldInterface) { + case 'datetime': + return { + ...schema, + 'x-component-props': { + ...defaultSchema['x-component-props'], + showTime: true, + }, + }; + default: + return { + ...schema, + 'x-component-props': { + ...defaultSchema['x-component-props'], + }, + }; + } + }, + }; +}; + +export const useChartData = () => { + const { charts } = useContext(ChartDataContext); + + const getChartCollections = () => + Array.from( + new Set( + Object.values(charts) + .filter((chart) => chart) + .map((chart) => chart.collection), + ), + ); + + return { + getChartCollections, + }; +}; + +export const useChartFilter = () => { + const { charts } = useContext(ChartDataContext); + const { fieldSchema } = useActionContext(); + const action = fieldSchema?.['x-action']; + const { getCollection, getInterface, getCollectionFields, getCollectionJoinField } = useCollectionManager(); + const { fields: fieldProps, form } = useContext(ChartFilterContext); + + const getChartFilterFields = (collection: CollectionOptions) => { + const fields = getCollectionFields(collection); + const field2item = (field: any, title: string, name: string) => { + const fieldTitle = field.uiSchema?.title || field.name; + const interfaceConfig = getInterface(field.interface); + const defaultOperator = interfaceConfig?.filterable?.operators?.[0]; + const targetCollection = getCollection(field.target); + title = title ? `${title} / ${fieldTitle}` : fieldTitle; + let schema = { + type: 'string', + title, + name: `${name}.${field.name}`, + required: false, + 'x-designer': 'ChartFilterItemDesigner', + 'x-component': 'CollectionField', + 'x-decorator': 'ChartFilterFormItem', + 'x-collection-field': `${name}.${field.name}`, + 'x-component-props': { + ...field.uiSchema?.['x-component-props'], + 'filter-operator': defaultOperator, + }, + }; + if (field.interface === 'formula') { + schema = { + ...schema, + 'x-component': 'InputNumber', + }; + } + if (['oho', 'o2m'].includes(field.interface)) { + schema['x-component-props'].useOriginalFilter = true; + } + const resultItem: SchemaInitializerItemType = { + key: `${name}.${field.name}`, + name: field.name, + type: 'item', + title: field?.uiSchema?.title || field.name, + Component: 'CollectionFieldInitializer', + remove: (schema, cb) => { + cb(schema, { + breakRemoveOn: { + 'x-component': 'Grid', + }, + }); + }, + schemaInitialize: (s: any) => { + interfaceConfig?.schemaInitialize?.(s, { + field, + block: 'FilterForm', + readPretty: form.readPretty, + action, + targetCollection, + }); + }, + schema, + }; + + return resultItem; + }; + + const children2item = (child: any, title: string, name: string) => { + const childTitle = child.uiSchema?.title || child.name; + title = title ? `${title} / ${childTitle}` : childTitle; + const defaultOperator = child.operators[0]; + let schema = { + type: 'string', + name: `${name}.${child.name}`, + required: false, + 'x-designer': 'ChartFilterItemDesigner', + 'x-decorator': 'ChartFilterFormItem', + 'x-collection-field': `${name}.${child.name}`, + ...child.schema, + title, + 'x-component-props': { + 'filter-operator': defaultOperator, + }, + }; + if (defaultOperator?.noValue) { + schema = { + ...schema, + 'x-component': 'ChartFilterCheckbox', + 'x-component-props': { + ...schema['x-component-props'], + content: Schema.compile(defaultOperator.label, { t: i18n.t }), + }, + }; + } + const resultItem: SchemaInitializerItemType = { + key: `${name}.${child.name}`, + name: child.name, + type: 'item', + title: child.title || child.name, + Component: 'CollectionFieldInitializer', + remove: (schema, cb) => { + cb(schema, { + breakRemoveOn: { + 'x-component': 'Grid', + }, + }); + }, + schema, + }; + + return resultItem; + }; + + const field2option = (field: any, depth: number, title: string, name: string): SchemaInitializerItemType => { + if (!field.interface) { + return; + } + const fieldInterface = getInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested, children } = fieldInterface.filterable; + const fieldTitle = field.uiSchema?.title || field.name; + const item = field2item(field, title, name); + 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}`)); + return { + key: `${name}.${field.name}`, + name: field.name, + type: 'subMenu', + title: field?.uiSchema?.title || field.name, + children: items, + }; + } + if (!depth || depth > 2) { + return item; + } + if (nested) { + const targetFields = getCollectionFields(field.target); + const items = targetFields.map((targetField) => + field2option(targetField, depth + 1, '', `${name}.${field.name}`), + ); + return { + key: `${name}.${field.name}`, + name: field.name, + type: 'subMenu', + title: field?.uiSchema?.title || field.name, + children: items.filter(Boolean), + }; + } + return item; + }; + + const options: SchemaInitializerItemType[] = []; + const associationOptions = []; + fields.forEach((field) => { + const fieldInterface = field.interface; + const option = field2option(field, 0, '', collection.name); + if (option) { + options.push(option); + } + if (['m2o'].includes(fieldInterface)) { + const option = field2option(field, 1, '', collection.name); + if (option) { + associationOptions.push(option); + } + } + }); + if (associationOptions.length) { + options.push( + { + name: 'divider', + type: 'divider', + }, + { + name: 'displayAssociationFields', + type: 'itemGroup', + title: i18n.t('Display association fields'), + children: associationOptions, + }, + ); + } + return options; + }; + + 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; + } + }); + return filter; + }; + + const hasFilter = (chart: { collection: string; query: any }, filterValues: any) => { + const { collection, query } = chart; + const { parameters } = parse(query.filter || ''); + return ( + chart && + (filterValues[collection] || + (filterValues['custom'] && parameters?.find((param: { key: string }) => filterValues['custom'][param.key]))) + ); + }; + + const appendFilter = (chart: { collection: string; query: any }, filterValues: any) => { + const { collection, query } = chart; + let newQuery = { ...query }; + const originFilter = { ...(newQuery.filter || {}) }; + let filter = {}; + const parsed = parse(originFilter); + const { parameters } = parsed; + if (filterValues['custom'] && parameters?.find((param: { key: string }) => filterValues['custom'][param.key])) { + filter = parsed(filterValues['custom']); + } + filter = deepmerge(originFilter, filter); + newQuery = { + ...newQuery, + filter: { + $and: [filter, filterValues[collection]], + }, + }; + return newQuery; + }; + + const filter = async () => { + const filterValues = getFilter(); + 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); + }); + await Promise.all(requests.map((request) => request())); + }; + + const refresh = async () => { + const requests = Object.values(charts) + .filter((chart) => { + return chart; + }) + .map((chart) => async () => { + const { service, collection, query } = chart; + await service.runAsync(collection, query, true); + }); + await Promise.all(requests.map((request) => request())); + }; + + const getTranslatedTitle = useMemoizedFn((title: string) => { + return title + .split(' / ') + .map((item: string) => i18n.t(Schema.compile(item, { t: i18n.t }))) + .join(' / '); + }); + + return { + filter, + refresh, + getChartFilterFields, + getFilter, + hasFilter, + appendFilter, + getTranslatedTitle, + }; +}; + +export const useFilterVariable = () => { + const { t: trans } = useChartsTranslation(); + const t = useMemoizedFn(trans); + const { enabled, fields } = useContext(ChartFilterContext); + const options = Object.entries(fields) + .filter(([name, value]) => name.startsWith('custom.') && value) + .map(([name, { title }]) => { + const value = name.replace(/^custom\./, ''); + return { + key: value, + value, + label: title, + }; + }); + const result = useMemo( + () => ({ + label: t('Current filter'), + value: '$nFilter', + key: '$nFilter', + children: options, + }), + [options, t], + ); + + if (!enabled || !options.length) return null; + + return result; +}; + +export const useChartFilterSourceFields = () => { + const { t } = useChartsTranslation(); + const { getChartCollections } = useChartData(); + const { getInterface, getCollectionFields, getCollection } = useCollectionManager(); + const { values } = useFieldComponents(); + const field2option = (field: any, depth: number) => { + if (!field.interface) { + return; + } + const fieldInterface = getInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested } = fieldInterface.filterable; + const item = { + value: field.name, + label: t(field?.uiSchema?.title || field.name), + }; + if (field.target && depth > 2) { + return; + } + if (depth > 2) { + return item; + } + if (nested) { + const targetFields = getCollectionFields(field.target); + const items = targetFields.map((targetField) => field2option(targetField, depth + 1)); + return { + value: field.name, + label: t(field?.uiSchema?.title || field.name), + children: items.filter((item: any) => item), + }; + } + if (!values.includes(field.uiSchema?.['x-component']) && !values.includes(field.interface)) { + return; + } + return item; + }; + + const collections = getChartCollections(); + 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, + }); + } + }); + return options; +}; + +export const useFieldComponents = () => { + const { t } = useChartsTranslation(); + const options = [ + { label: t('Input'), value: 'Input' }, + { label: t('Number'), value: 'InputNumber' }, + { label: t('Date'), value: 'DatePicker' }, + { label: t('Date range'), value: 'DatePicker.RangePicker' }, + { label: t('Time'), value: 'TimePicker' }, + { label: t('Time range'), value: 'TimePicker.RangePicker' }, + { label: t('Select'), value: 'Select' }, + { label: t('Radio group'), value: 'Radio.Group' }, + { label: t('Checkbox group'), value: 'Checkbox.Group' }, + // { label: t('China region'), value: 'chinaRegion' }, + ]; + return { + options, + values: options.map((option) => option.value), + }; +}; + +export const useCollectionJoinFieldTitle = (name: string) => { + const { getCollection, getCollectionField } = useCollectionManager(); + return useMemo(() => { + if (!name) { + return; + } + const [collectionName, ...fieldNames] = name.split('.'); + if (!fieldNames?.length) { + return; + } + const collection = getCollection(collectionName); + let cName: any = collectionName; + let field: any; + let title = Schema.compile(collection?.title, { t: i18n.t }); + while (cName && fieldNames.length > 0) { + const fileName = fieldNames.shift(); + field = getCollectionField(`${cName}.${fileName}`); + const fieldTitle = field?.uiSchema?.title || field?.name; + if (fieldTitle) { + title += ` / ${Schema.compile(fieldTitle, { t: i18n.t })}`; + } + if (field?.target) { + cName = field.target; + } else { + cName = null; + } + } + return title; + }, [name]); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/index.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/index.ts index 9b5a5c748..f20383e67 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/index.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/index.ts @@ -1,3 +1,4 @@ export * from './query'; export * from './transformer'; export * from './useVariableOptions'; +export * from './filter'; 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 6fc71f184..0a1c3f54d 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 @@ -3,7 +3,7 @@ import { ISchema, Schema, useForm } from '@formily/react'; import { CollectionFieldOptions, useACLRoleContext, useCollectionManager } from '@nocobase/client'; import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChartConfigContext } from '../configure/ChartConfigure'; +import { ChartConfigContext } from '../configure'; import formatters from '../block/formatters'; import { useChartsTranslation } from '../locale'; import { ChartRendererContext } from '../renderer'; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useDateVariable.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useDateVariable.ts index ff0bb2b92..a431da155 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useDateVariable.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useDateVariable.ts @@ -2,19 +2,19 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useMemoizedFn } from 'ahooks'; -export const useDateVariable = ({ operator, schema }) => { +export const useDateVariable = ({ schema }) => { const { t: trans } = useTranslation(); const t = useMemoizedFn(trans); - const operatorValue = operator?.value || ''; const component = schema?.['x-component']; - const disabled = !['DatePicker', 'DatePicker.RangePicker'].includes(component); + // const disabled = !['DatePicker', 'DatePicker.RangePicker'].includes(component); + const disabled = false; const dateOptions = useMemo( () => [ { key: 'now', value: 'now', label: t('Now'), - disabled: component !== 'DatePicker' || operatorValue === '$dateBetween', + disabled, }, { key: 'yesterday', @@ -143,7 +143,7 @@ export const useDateVariable = ({ operator, schema }) => { disabled, }, ], - [disabled, operatorValue, t, component], + [disabled, t], ); const result = useMemo( @@ -157,7 +157,7 @@ export const useDateVariable = ({ operator, schema }) => { [dateOptions, t], ); - if (!operator || !schema) return null; + if (!schema) return null; return result; }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useUserVariable.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useUserVariable.ts index 689ca8bf0..4a7308d64 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useUserVariable.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useUserVariable.ts @@ -15,7 +15,7 @@ export const useUserVariable = ({ schema }: { schema: any }) => { const result = { key: option.name, value: option.name, - disabled: !option.children && component !== option.schema?.['x-component'], + // disabled: !option.children && component !== option.schema?.['x-component'], label: Schema.compile(option.title, { t }), }; if (option.children) { @@ -23,7 +23,7 @@ export const useUserVariable = ({ schema }: { schema: any }) => { } return result; }), - [t, component], + [t], ); const children = useMemo(() => getOptions(options), [getOptions, options]); const result = useMemo( diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useVariableOptions.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useVariableOptions.ts index 6f151f5d1..9eee71254 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useVariableOptions.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/useVariableOptions.ts @@ -1,15 +1,22 @@ import { useField } from '@formily/react'; import { useMemo } from 'react'; -import { useDateVariable } from './useDateVariable'; -import { useUserVariable } from './useUserVariable'; +import { useFilterVariable } from './filter'; +import { useDateVariable, useUserVariable } from '@nocobase/client'; export const useVariableOptions = () => { const field = useField(); const { operator, schema } = field.data || {}; - const userVariable = useUserVariable({ schema }); + const userVariable = useUserVariable({ + collectionField: { uiSchema: schema }, + uiSchema: schema, + }); const dateVariable = useDateVariable({ operator, schema }); + const filterVariable = useFilterVariable(); - const result = useMemo(() => [userVariable, dateVariable].filter(Boolean), [dateVariable, userVariable]); + const result = useMemo( + () => [userVariable, dateVariable, filterVariable].filter(Boolean), + [dateVariable, userVariable, filterVariable], + ); if (!operator || !schema) return []; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/index.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/index.tsx index 0423b7f33..793222d4a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/index.tsx @@ -3,7 +3,9 @@ import { ChartV2Block, ChartV2BlockDesigner, ChartV2BlockInitializer, chartIniti import antd from './chart/antd'; import g2plot from './chart/g2plot'; import { ChartGroup } from './chart/group'; -import { ChartRenderer, ChartRendererProvider } from './renderer'; +import { lang } from './locale'; +import { chartFilterActionInitializers, chartFilterItemInitializers } from './filter'; + class DataVisualizationPlugin extends Plugin { public charts: ChartGroup = new ChartGroup(); @@ -12,17 +14,17 @@ class DataVisualizationPlugin extends Plugin { this.app.addComponents({ ChartV2BlockInitializer, - ChartRenderer, ChartV2BlockDesigner, ChartV2Block, - ChartRendererProvider, }); this.app.schemaInitializerManager.add(chartInitializers); + this.app.schemaInitializerManager.add(chartFilterItemInitializers); + this.app.schemaInitializerManager.add(chartFilterActionInitializers); const blockInitializers = this.app.schemaInitializerManager.get('BlockInitializers'); blockInitializers?.add('dataBlocks.chartV2', { - title: '{{t("Charts")}}', + title: lang('Charts'), Component: 'ChartV2BlockInitializer', }); } @@ -31,6 +33,6 @@ class DataVisualizationPlugin extends Plugin { export default DataVisualizationPlugin; export { Chart } from './chart/chart'; export type { ChartProps, ChartType, RenderProps } from './chart/chart'; -export { ChartConfigContext } from './configure/ChartConfigure'; export type { FieldOption } from './hooks'; export type { QueryProps } from './renderer'; +export { ChartConfigContext } from './configure'; 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 855d36238..8185ba383 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 @@ -13,12 +13,13 @@ import { import { Empty, Result, Spin, Typography } from 'antd'; import React, { useContext } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { useChart } from '../chart/group'; -import { ChartConfigContext } from '../configure/ChartConfigure'; +import { ChartConfigContext } from '../configure'; import { useData, useFieldTransformer, useFieldsWithAssociation } from '../hooks'; import { useChartsTranslation } from '../locale'; import { createRendererSchema, getField } from '../utils'; import { ChartRendererContext } from './ChartRendererProvider'; +import { useChart } from '../chart/group'; +import { ChartDataContext } from '../block/ChartDataProvider'; const { Paragraph, Text } = Typography; export const ChartRenderer: React.FC & { @@ -44,7 +45,7 @@ export const ChartRenderer: React.FC & { if (!props[name]) { const field = getField(fields, name.split('.')); const transformer = transformers[name]; - props[name] = { label: field?.label || name, transformer }; + props[name] = { label: field?.label || name, transformer, interface: field?.interface }; } return props; }, {}), @@ -78,6 +79,7 @@ export const ChartRenderer: React.FC & { ChartRenderer.Designer = function Designer() { const { t } = useChartsTranslation(); const { setVisible, setCurrent } = useContext(ChartConfigContext); + const { removeChart } = useContext(ChartDataContext); const { service } = useContext(ChartRendererContext); const field = useField(); const schema = useFieldSchema(); @@ -88,8 +90,8 @@ ChartRenderer.Designer = function Designer() { { - setCurrent({ schema, field, collection: name, service, data: service?.data }); + onClick={async () => { + setCurrent({ schema, field, collection: name, service, data: service.data }); setVisible(true); }} > @@ -109,6 +111,11 @@ ChartRenderer.Designer = function Designer() { breakRemoveOn={{ 'x-component': 'ChartV2Block', }} + confirm={{ + onOk: () => { + removeChart(schema['x-uid']); + }, + }} /> ); 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 0540f621a..a61c8dcc6 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,8 +1,11 @@ -import { css } from '@emotion/css'; import { useFieldSchema } from '@formily/react'; import { MaybeCollectionProvider, useAPIClient, useRequest } from '@nocobase/client'; -import React, { createContext } from 'react'; -import { parseField } from '../utils'; +import React, { createContext, useContext } from 'react'; +import { parseField, removeUnparsableFilter } from '../utils'; +import { ChartDataContext } from '../block/ChartDataProvider'; +import { ConfigProvider } from 'antd'; +import { useChartFilter } from '../hooks'; +import { ChartFilterContext } from '../filter/FilterProvider'; export type MeasureProps = { field: string | string[]; @@ -58,12 +61,23 @@ export const ChartRendererContext = createContext< export const ChartRendererProvider: React.FC = (props) => { const { query, config, collection, transform } = 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) => + (collection, query, manual) => new Promise((resolve, reject) => { + // Check if the chart is configured if (!(collection && query?.measures?.length)) return resolve(undefined); + // If the filter block is enabled, the filter form is required to be rendered + if (enabled && !form) return resolve(undefined); + const filterValues = getFilter(); + const queryWithFilter = + !manual && hasFilter({ collection, query }, filterValues) + ? appendFilter({ collection, query }, filterValues) + : query; api .request({ url: 'charts:query', @@ -71,7 +85,8 @@ export const ChartRendererProvider: React.FC = (props) => { data: { uid: schema?.['x-uid'], collection, - ...query, + ...queryWithFilter, + filter: removeUnparsableFilter(queryWithFilter.filter), dimensions: (query?.dimensions || []).map((item: DimensionProps) => { const dimension = { ...item }; if (item.format && !item.alias) { @@ -92,27 +107,27 @@ export const ChartRendererProvider: React.FC = (props) => { }) .then((res) => { resolve(res?.data?.data); + if (!manual && schema?.['x-uid']) { + addChart(schema?.['x-uid'], { collection, service, query }); + } }) .catch(reject); }), { defaultParams: [collection, query], + // Wait until CharrFilterProvider 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), }, ); return ( -
+ {props.children} -
+
); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts index 13be66898..0bdc9c8da 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/utils.ts @@ -1,8 +1,9 @@ import { Schema } from '@formily/react'; import { uid } from '@formily/shared'; -import { SelectedField } from './configure/ChartConfigure'; +import { SelectedField } from './configure'; import { FieldOption } from './hooks'; import { QueryProps } from './renderer'; +import lodash from 'lodash'; export const createRendererSchema = (decoratorProps: any, componentProps = {}) => { const { collection } = decoratorProps; @@ -100,3 +101,35 @@ export const processData = (selectedFields: FieldOption[], data: any[], scope: a return processed; }); }; + +export const removeUnparsableFilter = (filter: any) => { + if (typeof filter === 'object' && filter !== null) { + if (Array.isArray(filter)) { + const newLogic = filter.map((condition) => removeUnparsableFilter(condition)).filter(Boolean); + return newLogic.length > 0 ? newLogic : null; + } else { + const newLogic = {}; + for (const key in filter) { + const value = removeUnparsableFilter(filter[key]); + if (value && !(typeof value === 'object' && Object.keys(value).length === 0)) { + newLogic[key] = value; + } + } + return Object.keys(newLogic).length > 0 ? newLogic : null; + } + } else if (typeof filter === 'string' && filter.startsWith('{{$nFilter.') && filter.endsWith('}}')) { + return null; + } + return filter; +}; + +export const getValuesByPath = (values: any, path: string) => { + const keys = path.split('.'); + let result = lodash.get(values, keys.slice(0, -1).join('.')); + if (Array.isArray(result)) { + result = result.map((item) => lodash.get(item, keys.slice(-1)[0])); + } else { + result = lodash.get(result, keys.slice(-1)[0]); + } + return result; +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.ts index 056d2b670..cd2bcdef4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/en-US.ts @@ -20,4 +20,15 @@ export default { 'Are you sure you want to delete it?': 'Are you sure you want to delete it?', 'Sign in button name, which will be displayed on the sign in page': 'Sign in button name, which will be displayed on the sign in page', + Expand: 'Expand', + 'Current filter': 'Current filter', + 'Add custom field': 'Add custom field', + 'To filter with custom fields, use "Current filter" variables in the chart configuration.': + 'To filter with custom fields, use "Current filter" variables in the chart configuration.', + Input: 'Input', + 'Date range': 'Date range', + 'Time range': 'Time range', + 'Edit field properties': 'Edit field properties', + 'Select a source field to use metadata of the field': 'Select a source field to use metadata of the field', + 'Original field': 'Original field', }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.ts index 0afc6779d..eb791f3da 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/locale/zh-CN.ts @@ -73,4 +73,16 @@ export default { isPercent: '显示为百分比', isGroup: '分组', smooth: '平滑曲线', + Collapse: '折叠', + Expand: '展开', + 'Current filter': '当前筛选', + 'Add custom field': '添加自定义字段', + 'To filter with custom fields, use "Current filter" variables in the chart configuration.': + '要通过自定义字段筛选,需要在图表配置中使用“当前筛选”变量。', + Input: '输入框', + 'Date range': '日期范围', + 'Time range': '时间范围', + 'Edit field properties': '编辑字段属性', + 'Select a source field to use metadata of the field': '选择来源字段可以复用字段的元数据配置', + 'Original field': '原始字段', };