tachybase_todo/packages/plugins/data-visualization/src/client/block/ChartConfigure.tsx
YANG QIA 3aa65cb30c
feat: data visualization (#2160)
* 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>
2023-06-30 20:49:44 +08:00

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 />
);
};