Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,599 changes: 1,584 additions & 15 deletions superset-frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions superset-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-univer": "file:./plugins/plugin-chart-univer",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export enum VizType {
Timeseries = 'echarts_timeseries',
Tree = 'tree_chart',
Treemap = 'treemap_v2',
UniverSheet = 'univer_sheet',
Waterfall = 'waterfall',
WordCloud = 'word_cloud',
WorldMap = 'world_map',
Expand Down
49 changes: 49 additions & 0 deletions superset-frontend/plugins/plugin-chart-univer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

## @superset-ui/plugin-chart-univer

An Excel-like spreadsheet chart built on [Univer](https://github.com/dream-num/univer) (open source core only).

- **Explore:** the query result is loaded into a full spreadsheet editor (toolbar, formula bar,
sheet tabs, context menu). Edits are written to the `univer_workbook` control and saved with the chart.
- **Dashboards:** the saved sheet is shown read-only, without toolbars or editors.

### Data refresh

With **Refresh data on load** on (the default), the query result is written into the top-left corner
of the first sheet every time the chart loads. Formatting, formulas and anything outside that area are
kept, and the query values are not stored in the chart. Put your own formulas to the right of the data
or on another sheet, because the data area grows when the query returns more rows or columns.

With it off, the sheet is shown exactly as it was saved.

### Usage

```js
import { UniverChartPlugin } from '@superset-ui/plugin-chart-univer';

new UniverChartPlugin().configure({ key: 'univer_sheet' }).register();
```

### Univer packages

Only Univer's open source packages (`@univerjs/presets` and `@univerjs/preset-sheets-core`,
Apache-2.0) are used. Pivot tables, row groups and the Excel export are implemented in this
plugin and in `superset/utils/univer_excel.py` instead of Univer's paid add-ons.
44 changes: 44 additions & 0 deletions superset-frontend/plugins/plugin-chart-univer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@superset-ui/plugin-chart-univer",
"version": "0.20.3",
"description": "Superset Chart - Excel-like spreadsheet powered by Univer",
"keywords": [
"superset"
],
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-univer#readme",
"bugs": {
"url": "https://github.com/apache/superset/issues"
},
"license": "Apache-2.0",
"author": "Superset",
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/plugin-chart-univer"
},
"files": [
"esm",
"lib"
],
"sideEffects": [
"*.css"
],
"main": "lib/index.js",
"module": "esm/index.js",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@univerjs/preset-sheets-core": "1.0.4",
"@univerjs/presets": "1.0.4",
"lodash-es": "^4.18.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"rxjs": "^7.8.1"
}
}
258 changes: 258 additions & 0 deletions superset-frontend/plugins/plugin-chart-univer/src/UniverChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useRef } from 'react';
import { debounce } from 'lodash-es';
import { styled } from '@apache-superset/core/theme';
import { mountUniver } from './univer/mountUniver';
import { UniverHandle } from './runtimeTypes';
import { UniverChartProps, WORKBOOK_CONTROL } from './types';
import {
buildGrid,
buildPivotGrid,
buildWorkbook,
groupRows,
DEFAULT_LAYOUT,
columnWidth,
fillWidth,
applyOutline,
sizeHeaders,
parseSavedState,
serializeState,
StretchedColumns,
stretchColumns,
unstretchColumns,
} from './utils/workbook';
import {
combineGroups,
EMPTY_OUTLINE,
MANUAL_GROUPS,
Outline,
outlineDepth,
outlineMargin,
resolveOutline,
} from './utils/outline';

// wait for a burst of edits (typing, pasting, dragging) to finish before saving
const SAVE_DELAY_MS = 300;
// wait for the chart to stop resizing before widening the columns again
const RESIZE_DELAY_MS = 100;

const Container = styled.div<{ width: number; height: number }>`
width: ${({ width }) => width}px;
height: ${({ height }) => height}px;
position: relative;
`;

export default function UniverChart(props: UniverChartProps) {
const {
width,
height,
data,
colnames,
coltypes,
labels,
editable,
savedState,
liveData,
showHeaders,
setControlValue,
pivotRows,
keyColumns,
rowGrouping,
} = props;
const containerRef = useRef<HTMLDivElement>(null);

// `labels` is a new object on every render, compare it by content so that
// saving an edit doesn't rebuild the whole spreadsheet
const labelsKey = JSON.stringify(labels);
const { grid, layout, groups } = useMemo(() => {
const result = pivotRows
? buildPivotGrid(
data,
colnames,
coltypes,
JSON.parse(labelsKey),
pivotRows,
)
: {
grid: buildGrid(data, colnames, coltypes, JSON.parse(labelsKey)),
layout: DEFAULT_LAYOUT,
};
return rowGrouping && keyColumns && keyColumns > 1
? // a pivot has its own Total row
groupRows(result.grid, result.layout, keyColumns, !pivotRows)
: { ...result, groups: [] };
}, [data, colnames, coltypes, labelsKey, pivotRows, rowGrouping, keyColumns]);

// The saved state changes in two ways: the chart saved an edit (the sheet on
// screen already shows it, so nothing to do), or something else changed it,
// like "Reset spreadsheet" or loading another chart (rebuild the sheet).
const lastSavedRef = useRef<string | null>(null);
const latestStateRef = useRef(savedState);
const externalRef = useRef({ state: savedState, version: 0 });
latestStateRef.current = savedState;
if (savedState !== externalRef.current.state) {
externalRef.current = {
state: savedState,
version:
externalRef.current.version +
(savedState === lastSavedRef.current ? 0 : 1),
};
}
const externalVersion = externalRef.current.version;

const setControlValueRef = useRef(setControlValue);
setControlValueRef.current = setControlValue;

// columns are widened to fill the chart, that is not saved (see
// unstretchColumns) so the sheet fits again at any other size
const widthRef = useRef(width);
widthRef.current = width;
const handleRef = useRef<UniverHandle | null>(null);
const stretchedRef = useRef<StretchedColumns>({});
// collapsible groups of the sheet on screen
const outlineRef = useRef<Outline>(EMPTY_OUTLINE);

useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;

const saved = parseSavedState(latestStateRef.current);
// groups are tied to the query's rows when they come from the query
const savedOutline = resolveOutline(
saved?.outline,
liveData || saved?.stripped ? grid : [],
layout.headerRows,
keyColumns,
);
// without manual groups, only the collapsed state of the automatic ones
// is taken from the saved chart
const savedRows = MANUAL_GROUPS
? savedOutline.rows
: savedOutline.rows.filter(group =>
groups.some(
auto => auto.start === group.start && auto.end === group.end,
),
);
outlineRef.current = {
rows: combineGroups(groups, savedRows),
columns: MANUAL_GROUPS ? savedOutline.columns : [],
};
const mountedOutline = outlineRef.current;
const headerLabels = editable || showHeaders;
const workbookData = sizeHeaders(
applyOutline(
buildWorkbook(grid, saved, liveData, layout),
mountedOutline,
grid,
),
headerLabels,
mountedOutline,
);
const fitted = stretchColumns(workbookData, widthRef.current);
stretchedRef.current = fitted.stretched;

let handle: UniverHandle | null = null;
const save = debounce(() => {
if (!handle) return;
const state = serializeState(
unstretchColumns(handle.save(), stretchedRef.current),
grid,
liveData,
layout,
{ groups: outlineRef.current, keyColumns },
);
if (state === lastSavedRef.current || state === latestStateRef.current) {
return;
}
lastSavedRef.current = state;
// a rebuild right after this flush must start from this state, the
// new props haven't arrived yet
latestStateRef.current = state;
setControlValueRef.current?.(WORKBOOK_CONTROL, state);
}, SAVE_DELAY_MS);

handle = mountUniver({
container,
workbook: fitted.workbook,
editable,
onChange: editable ? save : undefined,
// the margin for row groups grows and shrinks as groups change
getFillWidth: () =>
fillWidth(fitted.workbook, widthRef.current) +
outlineMargin(outlineDepth(mountedOutline.rows)) -
outlineMargin(outlineDepth(outlineRef.current.rows)),
outline: mountedOutline,
headerLabels,
outlineSummaryAbove: groups.length > 0,
onOutlineChange: editable
? outline => {
outlineRef.current = outline;
save();
}
: undefined,
});
handleRef.current = handle;

return () => {
// keep edits made just before the chart re-renders or Explore closes
save.flush();
handleRef.current = null;
handle?.dispose();
};
}, [
grid,
layout,
groups,
keyColumns,
editable,
liveData,
showHeaders,
externalVersion,
]);

useEffect(() => {
const restretch = debounce(() => {
const handle = handleRef.current;
if (!handle) return;
const current = handle.save();
const base = unstretchColumns(current, stretchedRef.current);
const { stretched } = stretchColumns(base, width);
const widths: Record<number, number> = {};
const columns = new Set([
...Object.keys(stretchedRef.current),
...Object.keys(stretched),
]);
columns.forEach(key => {
const c = Number(key);
const target = stretched[c]?.w ?? columnWidth(base, c);
if (target !== undefined && target !== columnWidth(current, c)) {
widths[c] = target;
}
});
// set before the widths change, so saving sees them as stretched
stretchedRef.current = stretched;
if (Object.keys(widths).length) handle.setColumnWidths(widths);
}, RESIZE_DELAY_MS);
restretch();
return () => restretch.cancel();
}, [width]);

return <Container ref={containerRef} width={width} height={height} />;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions superset-frontend/plugins/plugin-chart-univer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line import/prefer-default-export
export { default as UniverChartPlugin } from './plugin';
Loading
Loading