<!-- Note --> <!-- This is a template for submitting a new feature. Use the bug fix template if you're submitting a bug fix pull request by adding `template=bug_fix.md` to your pull request URL. --> <!-- Describe the new feature or modification to an existing feature clearly and consciously. --> <!-- Explain the reason for adding or modifying this feature. --> <!-- Provide a technically detailed description of the key changes made. --> - Frontend - Backend <!-- Provide any suggestions or recommendations for improvements in the testing plan. --> <!-- Identify any potential risks or issues that may arise from the new feature or modification. --> <!-- Including any screenshots of the new feature or modification. --> Co-authored-by: sealday <sealday@gmail.com> Co-authored-by: wjh <wwwjh0710@163.com> Co-authored-by: 吕延祥 <2256334253@qq.com> Reviewed-on: daoyoucloud/nocobase#317
440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
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 { 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,
|
|
useData,
|
|
useFieldTypes,
|
|
useFieldsWithAssociation,
|
|
useFormatters,
|
|
useOrderFieldsOptions,
|
|
useOrderReaction,
|
|
useTransformers,
|
|
useTransformersDecimal,
|
|
} from '../hooks';
|
|
import { useChartsTranslation } from '../locale';
|
|
import { ChartRenderer, ChartRendererContext } from '../renderer';
|
|
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 { ChartConfigContext } from './ChartConfigProvider';
|
|
const { Paragraph, Text } = Typography;
|
|
|
|
export type SelectedField = {
|
|
field: string | string[];
|
|
alias?: string;
|
|
};
|
|
|
|
export const ChartConfigure: React.FC<{
|
|
insert: (
|
|
s: ISchema,
|
|
options: {
|
|
onSuccess: () => void;
|
|
wrap?: (schema: ISchema) => ISchema;
|
|
},
|
|
) => void;
|
|
}> & {
|
|
Renderer: React.FC;
|
|
Config: React.FC;
|
|
Query: React.FC;
|
|
Transform: React.FC;
|
|
Data: React.FC;
|
|
} = (props) => {
|
|
const { t } = useChartsTranslation();
|
|
const { service } = useContext(ChartRendererContext);
|
|
const { visible, setVisible, current } = useContext(ChartConfigContext);
|
|
const { schema, field, collection, initialValues } = current || {};
|
|
const { dn } = useDesignable();
|
|
const { modal } = App.useApp();
|
|
const { insert } = props;
|
|
|
|
const charts = useCharts();
|
|
const fields = useFieldsWithAssociation(collection);
|
|
const initChart = (overwrite = false) => {
|
|
if (!form.modified) {
|
|
return;
|
|
}
|
|
const chartType = form.values.config?.chartType;
|
|
if (!chartType) {
|
|
return;
|
|
}
|
|
const chart = charts[chartType];
|
|
const init = chart?.init;
|
|
if (!init) {
|
|
if (overwrite) {
|
|
form.values.config.general = {};
|
|
form.values.config.advanced = {};
|
|
}
|
|
return;
|
|
}
|
|
const query = form.values.query;
|
|
const selectedFields = getSelectedFields(fields, query);
|
|
const { general, advanced } = chart.init(selectedFields, query);
|
|
if (general || overwrite) {
|
|
form.values.config.general = general;
|
|
}
|
|
if (advanced || overwrite) {
|
|
form.values.config.advanced = advanced || {};
|
|
}
|
|
};
|
|
|
|
const [measures, setMeasures] = React.useState([]);
|
|
const [dimensions, setDimensions] = React.useState([]);
|
|
const queryReact = (form: FormType, reaction?: () => void) => {
|
|
const currentMeasures = form.values.query?.measures.filter((item) => item.field) || [];
|
|
const currentDimensions = form.values.query?.dimensions.filter((item) => item.field) || [];
|
|
if (isEqual(currentMeasures, measures) && isEqual(currentDimensions, dimensions)) {
|
|
return;
|
|
}
|
|
reaction?.();
|
|
setMeasures(cloneDeep(currentMeasures));
|
|
setDimensions(cloneDeep(currentDimensions));
|
|
};
|
|
const chartType = useDefaultChartType();
|
|
const form = useMemo(
|
|
() =>
|
|
createForm({
|
|
values: { config: { chartType }, ...(initialValues || field?.decoratorProps), collection },
|
|
effects: (form) => {
|
|
onFieldChange('config.chartType', () => initChart(true));
|
|
onFormInit(() => queryReact(form));
|
|
},
|
|
}),
|
|
// visible, collection added here to re-initialize form when visible, collection change
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[field, visible, collection],
|
|
);
|
|
|
|
const RunButton: React.FC = () => (
|
|
<Button
|
|
type="link"
|
|
loading={service?.loading}
|
|
icon={<RightSquareOutlined />}
|
|
onClick={async () => {
|
|
const queryField = form.query('query').take() as ObjectField;
|
|
try {
|
|
await queryField?.validate();
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await service.runAsync(collection, form.values.query, true);
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
queryReact(form, initChart);
|
|
}}
|
|
>
|
|
{t('Run query')}
|
|
</Button>
|
|
);
|
|
|
|
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 (
|
|
<Modal
|
|
title={t('Configure chart')}
|
|
open={visible}
|
|
onOk={() => {
|
|
const { query, config, transform, mode } = form.values;
|
|
const afterSave = () => {
|
|
setVisible(false);
|
|
current.service?.run(collection, query);
|
|
queryRef.current.scrollTop = 0;
|
|
configRef.current.scrollTop = 0;
|
|
service.mutate(undefined);
|
|
};
|
|
const rendererProps = {
|
|
query,
|
|
config,
|
|
collection,
|
|
transform,
|
|
mode: mode || 'builder',
|
|
};
|
|
if (schema && schema['x-uid']) {
|
|
schema['x-decorator-props'] = rendererProps;
|
|
field.decoratorProps = rendererProps;
|
|
field['x-acl-action'] = `${collection}:list`;
|
|
dn.emit('patch', {
|
|
schema,
|
|
});
|
|
afterSave();
|
|
return;
|
|
}
|
|
insert(gridRowColWrap(createRendererSchema(rendererProps)), {
|
|
onSuccess: afterSave,
|
|
});
|
|
}}
|
|
onCancel={() => {
|
|
modal.confirm({
|
|
title: t('Are you sure to cancel?'),
|
|
content: t('You changes are not saved. If you click OK, your changes will be lost.'),
|
|
okButtonProps: {
|
|
danger: true,
|
|
},
|
|
onOk: () => {
|
|
setVisible(false);
|
|
queryRef.current.scrollTop = 0;
|
|
configRef.current.scrollTop = 0;
|
|
service.mutate(undefined);
|
|
},
|
|
});
|
|
}}
|
|
width={'95%'}
|
|
styles={{
|
|
body: {
|
|
background: 'rgba(128, 128, 128, 0.08)',
|
|
},
|
|
}}
|
|
>
|
|
<FormProvider form={form}>
|
|
<FormLayout layout="vertical">
|
|
<Row gutter={8}>
|
|
<Col span={7}>
|
|
<Card
|
|
style={{
|
|
height: 'calc(100vh - 300px)',
|
|
overflow: 'auto',
|
|
margin: '12px 0 12px 12px',
|
|
}}
|
|
ref={queryRef}
|
|
>
|
|
<Tabs
|
|
tabBarExtraContent={<RunButton />}
|
|
items={[
|
|
{
|
|
label: t('Query'),
|
|
key: 'query',
|
|
children: <ChartConfigure.Query />,
|
|
},
|
|
{
|
|
label: t('Data'),
|
|
key: 'data',
|
|
children: <ChartConfigure.Data />,
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card
|
|
style={{
|
|
height: 'calc(100vh - 300px)',
|
|
overflow: 'auto',
|
|
margin: '12px 3px 12px 3px',
|
|
}}
|
|
ref={configRef}
|
|
>
|
|
<Tabs
|
|
items={[
|
|
{
|
|
label: t('Chart'),
|
|
key: 'chart',
|
|
children: <ChartConfigure.Config />,
|
|
},
|
|
{
|
|
label: t('Transform'),
|
|
key: 'transform',
|
|
children: <ChartConfigure.Transform />,
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={11}>
|
|
<Card
|
|
style={{
|
|
margin: '12px 12px 12px 0',
|
|
}}
|
|
>
|
|
<ChartConfigure.Renderer />
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</FormLayout>
|
|
</FormProvider>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Renderer = function Renderer(props) {
|
|
const { current } = useContext(ChartConfigContext);
|
|
const { collection, data } = current || {};
|
|
const { service } = useContext(ChartRendererContext);
|
|
return (
|
|
<FormConsumer>
|
|
{(form) => {
|
|
// Any change of config and transform will trigger rerender
|
|
// Change of query only trigger rerender when "Run query" button is clicked
|
|
const config = cloneDeep(form.values.config);
|
|
const transform = cloneDeep(form.values.transform);
|
|
return (
|
|
<ChartRendererContext.Provider value={{ collection, config, transform, service, data }}>
|
|
<ChartRenderer {...props} />
|
|
</ChartRendererContext.Provider>
|
|
);
|
|
}}
|
|
</FormConsumer>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Query = function Query() {
|
|
const { t } = useChartsTranslation();
|
|
const fields = useFieldsWithAssociation();
|
|
const useFormatterOptions = useFormatters(fields);
|
|
const collectionOptions = useCollectionOptions();
|
|
const { current, setCurrent } = useContext(ChartConfigContext);
|
|
const { collection } = current || {};
|
|
const fieldOptions = useCollectionFieldsOptions(collection, 1);
|
|
const compiledFieldOptions = Schema.compile(fieldOptions, { t });
|
|
const filterOptions = useCollectionFilterOptions(collection);
|
|
|
|
const { service } = useContext(ChartRendererContext);
|
|
const onCollectionChange = (value: string) => {
|
|
const { schema, field } = current;
|
|
setCurrent({
|
|
schema,
|
|
field,
|
|
collection: value,
|
|
dataSource: current.dataSource,
|
|
service: current.service,
|
|
initialValues: {},
|
|
data: undefined,
|
|
});
|
|
service.mutate(undefined);
|
|
};
|
|
|
|
const formCollapse = FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']);
|
|
const FromSql = () => (
|
|
<Text code>
|
|
From <span style={{ color: '#1890ff' }}>{current?.collection}</span>
|
|
</Text>
|
|
);
|
|
return (
|
|
<SchemaComponent
|
|
schema={querySchema}
|
|
scope={{
|
|
t,
|
|
formCollapse,
|
|
fieldOptions: compiledFieldOptions,
|
|
filterOptions,
|
|
useOrderOptions: useOrderFieldsOptions(compiledFieldOptions, fields),
|
|
collectionOptions,
|
|
useFormatterOptions,
|
|
onCollectionChange,
|
|
collection: current?.collection,
|
|
useOrderReaction: useOrderReaction(compiledFieldOptions, fields),
|
|
}}
|
|
components={{ ArrayItems, Editable, FormCollapse, FormItem, Space, Switch, FromSql, FilterDynamicComponent }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Config = function Config() {
|
|
const { t } = useChartsTranslation();
|
|
const chartTypes = useChartTypes();
|
|
const fields = useFieldsWithAssociation();
|
|
const charts = useCharts();
|
|
const getChartFields = useChartFields(fields);
|
|
const getReference = (chartType: string) => {
|
|
const reference = charts[chartType]?.getReference?.();
|
|
if (!reference) return '';
|
|
const { title, link } = reference;
|
|
return (
|
|
<span>
|
|
{t('Config reference: ')}
|
|
<a href={link} target="_blank" rel="noreferrer">
|
|
{t(title)}
|
|
</a>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<FormConsumer>
|
|
{(form) => {
|
|
const chartType = form.values.config?.chartType;
|
|
const chart = charts[chartType];
|
|
const schema = chart?.schema || {};
|
|
return (
|
|
<SchemaComponent
|
|
schema={getConfigSchema(schema)}
|
|
scope={{ t, chartTypes, useChartFields: getChartFields, getReference }}
|
|
components={{ FormItem, ArrayItems, Space, AutoComplete }}
|
|
/>
|
|
);
|
|
}}
|
|
</FormConsumer>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Transform = function Transform() {
|
|
const { t } = useChartsTranslation();
|
|
const fields = useFieldsWithAssociation();
|
|
const useFieldTypeOptions = useFieldTypes(fields);
|
|
const getChartFields = useChartFields(fields);
|
|
return (
|
|
<SchemaComponent
|
|
schema={transformSchema}
|
|
components={{ FormItem, ArrayItems, Space }}
|
|
scope={{ useChartFields: getChartFields, useFieldTypeOptions, useTransformers, useTransformersDecimal, t }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Data = function Data() {
|
|
const { service } = useContext(ChartRendererContext);
|
|
const { current } = useContext(ChartConfigContext);
|
|
const fields = useFieldsWithAssociation();
|
|
const data = useData(current?.data);
|
|
const error = service?.error;
|
|
return !error ? (
|
|
<Table
|
|
dataSource={data.map((item, index) => ({ ...item, _key: index }))}
|
|
rowKey="_key"
|
|
scroll={{ x: 'max-content' }}
|
|
columns={Object.keys(data[0] || {}).map((col) => {
|
|
const field = getField(fields, col.split('.'));
|
|
return {
|
|
title: field?.label || col,
|
|
dataIndex: col,
|
|
key: col,
|
|
};
|
|
})}
|
|
size="small"
|
|
/>
|
|
) : (
|
|
<Alert
|
|
message="Error"
|
|
type="error"
|
|
description={error?.response?.data?.errors?.map?.((error: any) => error.message).join('\n') || error.message}
|
|
showIcon
|
|
/>
|
|
);
|
|
};
|