* feat(charts-v2): init * chore(charts-v2): init chart renderer * feat(chart-v2): add chart grid and initializer * feat(chart-v2): improve ui * feat(chart-v2): ui * feat(charts-v2): query sort ui * feat(charts-v2): field select component * feat(charts-v2): improve ui && add query action * feat(charts-v2): imporve ui, work in progress * fix(charts-v2): chart renderer request api twice * feat(charts-v2): add dimension formatter * feat(charts-v2): filter, sort, limit * feat(charts-v2): sql mode ui * feat(charts-v2): support duplicate & sql mode * fix(charts-v2): wrong defaultValue of json config * feat(charts-v2): transformer ui * feat(charts-v2): transformer * chore(charts-v2): rename transfromer to transform * feat(charts-v2): support cache * feat(charts-v2): add acl provider * chore(charts-v2): hide sql mode * refactor(charts-v2): add renderer provider * feat: collection permission check * feat(charts-v2): add antd statistic * test(charts-v2): backend * chore: improve code * test(charts-v2): add test * chore: add Chinese translation * fix(charts-v2): locale switch bug * chore: add dependency * feat(charts-v2): init chart config from query * feat: change layout * test: fix frontend test * feat: improve auto infer * fix: ui issues * chore: translation * fix: sql error * fix: some issues * feat: support table * fix: bug * chore: improve code and fix query * feat: add config reference * chore: add translation * fix: process data due to pg issue * test: fix parseBuilder * chore: upgrade formily to 2.2.25 * fix: some issues and import style * fix: bug when query with sort * feat: parse enum data * fix: yarn.lock * fix: type error * fix: infer bug and frontend test * test: fix frontend * fix: test * feat: improve preview * chore: downgrade formily * feat: support associations, draft, in testing * fix: typo * test: frontend & backend * fix: infer bug * feat: measure selection of statistics * fix: bug of group by alias * fix: some issues * fix: order issues * fix: yarn.lock * chore: fix filter include & 'data-visualization' * style: improve style * docs: add readme * chore: add translation --------- Co-authored-by: chenos <chenlinxh@gmail.com>
451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
import { RightSquareOutlined } from '@ant-design/icons';
|
|
import { ArrayItems, Editable, Form, FormCollapse, FormItem, Switch } from '@formily/antd';
|
|
import { createForm, Form as FormType, ObjectField, onFieldChange, onFormInit } from '@formily/core';
|
|
import { FormConsumer, ISchema, Schema } from '@formily/react';
|
|
import {
|
|
AutoComplete,
|
|
Cascader,
|
|
DatePicker,
|
|
Filter,
|
|
gridRowColWrap,
|
|
Input,
|
|
InputNumber,
|
|
Radio,
|
|
SchemaComponent,
|
|
Select,
|
|
useCollectionFieldsOptions,
|
|
useCollectionFilterOptions,
|
|
useDesignable,
|
|
} from '@nocobase/client';
|
|
import { Alert, 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 { useTranslation } from 'react-i18next';
|
|
import {
|
|
useChartFields,
|
|
useCollectionOptions,
|
|
useFieldsWithAssociation,
|
|
useFieldTypes,
|
|
useFormatters,
|
|
useOrderFieldsOptions,
|
|
useOrderReaction,
|
|
useTransformers,
|
|
} from '../hooks';
|
|
import { useChartsTranslation } from '../locale';
|
|
import { ChartRenderer, ChartRendererProvider, useCharts, useChartTypes } from '../renderer';
|
|
import { createRendererSchema, getField, getSelectedFields } from '../utils';
|
|
import { getConfigSchema, querySchema, transformSchema } from './schemas/configure';
|
|
const { Paragraph, Text } = Typography;
|
|
|
|
export type ChartConfigCurrent = {
|
|
schema: ISchema;
|
|
field: any;
|
|
collection: string;
|
|
};
|
|
|
|
export type SelectedField = {
|
|
field: string | string[];
|
|
alias?: string;
|
|
};
|
|
|
|
export const ChartConfigContext = createContext<{
|
|
visible: boolean;
|
|
setVisible?: (visible: boolean) => void;
|
|
current?: ChartConfigCurrent;
|
|
setCurrent?: (current: ChartConfigCurrent) => void;
|
|
data?: any[] | string;
|
|
setData?: (data: any[] | string) => void;
|
|
}>({
|
|
visible: true,
|
|
});
|
|
|
|
export const ChartConfigure: React.FC<{
|
|
insert: (
|
|
s: ISchema,
|
|
options: {
|
|
onSuccess: () => void;
|
|
wrap?: (schema: ISchema) => ISchema;
|
|
},
|
|
) => void;
|
|
}> & {
|
|
Renderer: React.FC<{
|
|
runQuery?: any;
|
|
}>;
|
|
Config: React.FC;
|
|
Query: React.FC;
|
|
Transform: React.FC;
|
|
Data: React.FC;
|
|
} = (props) => {
|
|
const { t } = useChartsTranslation();
|
|
const { visible, setVisible, current } = useContext(ChartConfigContext);
|
|
const { schema, field, collection } = current || {};
|
|
const { dn } = useDesignable();
|
|
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 } = 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 chartTypes = useChartTypes();
|
|
const form = useMemo(
|
|
() => {
|
|
const chartType = chartTypes[0]?.children?.[0]?.value;
|
|
return createForm({
|
|
values: { config: { chartType }, ...schema?.['x-decorator-props'], collection, data: '' },
|
|
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
|
|
[schema, visible, collection],
|
|
);
|
|
|
|
const runQuery = useRef(null);
|
|
const RunButton: React.FC = () => {
|
|
const [loading, setLoading] = React.useState(false);
|
|
return (
|
|
<Button
|
|
type="link"
|
|
loading={loading}
|
|
icon={<RightSquareOutlined />}
|
|
onClick={async () => {
|
|
const queryField = form.query('query').take() as ObjectField;
|
|
try {
|
|
await queryField?.validate();
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
await runQuery?.current(form.values.query);
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
queryReact(form, initChart);
|
|
setLoading(false);
|
|
}}
|
|
>
|
|
{t('Run query')}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
const queryRef = useRef(null);
|
|
const configRef = useRef(null);
|
|
return (
|
|
<Modal
|
|
title={t('Configure chart')}
|
|
open={visible}
|
|
onOk={() => {
|
|
const { query, config, transform, mode } = form.values;
|
|
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,
|
|
});
|
|
setVisible(false);
|
|
queryRef.current.scrollTop = 0;
|
|
configRef.current.scrollTop = 0;
|
|
return;
|
|
}
|
|
insert(createRendererSchema(rendererProps), {
|
|
onSuccess: () => setVisible(false),
|
|
wrap: gridRowColWrap,
|
|
});
|
|
}}
|
|
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;
|
|
},
|
|
});
|
|
}}
|
|
width={'95%'}
|
|
bodyStyle={{
|
|
background: 'rgba(128, 128, 128, 0.08)',
|
|
}}
|
|
>
|
|
<Form layout="vertical" form={form}>
|
|
<Row gutter={8}>
|
|
<Col span={7}>
|
|
<Card
|
|
style={{
|
|
height: 'calc(100vh - 300px)',
|
|
overflow: 'scroll',
|
|
}}
|
|
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: 'scroll',
|
|
}}
|
|
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>
|
|
<ChartConfigure.Renderer runQuery={runQuery} />
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Renderer = function Renderer(props) {
|
|
const { current } = useContext(ChartConfigContext);
|
|
const { collection } = current || {};
|
|
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 (
|
|
<ChartRendererProvider
|
|
collection={collection}
|
|
query={form.values.query}
|
|
config={config}
|
|
transform={transform}
|
|
mode={form.values.mode}
|
|
>
|
|
<ChartRenderer configuring={true} {...props} />
|
|
</ChartRendererProvider>
|
|
);
|
|
}}
|
|
</FormConsumer>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Query = function Query() {
|
|
const { t } = useChartsTranslation();
|
|
const { t: lang } = useTranslation();
|
|
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: lang });
|
|
const filterOptions = useCollectionFilterOptions(collection);
|
|
const formCollapse = FormCollapse.createFormCollapse(['measures', 'dimensions', 'filter', 'sort']);
|
|
const onCollectionChange = (value: string) => {
|
|
const { schema, field } = current;
|
|
const newSchema = { ...schema };
|
|
newSchema['x-decorator-props'] = { collection: value };
|
|
newSchema['x-acl-action'] = `${value}:list`;
|
|
setCurrent({
|
|
schema: newSchema,
|
|
field,
|
|
collection: value,
|
|
});
|
|
};
|
|
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,
|
|
Card,
|
|
Switch,
|
|
Select,
|
|
Input,
|
|
InputNumber,
|
|
FormItem,
|
|
Radio,
|
|
Space,
|
|
Filter,
|
|
DatePicker,
|
|
Text,
|
|
FromSql,
|
|
Cascader,
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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]?.reference;
|
|
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={{ Card, Select, Input, 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={{ Select, FormItem, ArrayItems, Space }}
|
|
scope={{ useChartFields: getChartFields, useFieldTypeOptions, useTransformers, t }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
ChartConfigure.Data = function Data() {
|
|
const { data } = useContext(ChartConfigContext);
|
|
const fields = useFieldsWithAssociation();
|
|
return Array.isArray(data) ? (
|
|
<Table
|
|
dataSource={data}
|
|
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={data} showIcon />
|
|
);
|
|
};
|