Skip to content

Commit cb2ca13

Browse files
Serialize table column widths (#1411)
* Moves column width into model so it persists on reload and will sync with all views of the tile. * Allow column width to be specified in curriculum. * Patch react-data-grid so it alerts clients when width changes are complete and can optionally forget user specified widths. Co-authored-by: Kirk Swenson <[email protected]>
1 parent bc57361 commit cb2ca13

14 files changed

+687
-98
lines changed

package-lock.json

+489-55
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"lint:unused": "tsc --noUnusedLocals --project .",
6767
"migrate": "node ./migrations/migrate",
6868
"migrate:debug": "node --inspect-brk ./migrations/migrate",
69+
"postinstall": "patch-package",
6970
"start": "webpack-dev-server --hot",
7071
"start:secure": "webpack-dev-server --https --hot --cert ~/.localhost-ssl/localhost.pem --key ~/.localhost-ssl/localhost.key",
7172
"stats": "webpack --profile --json --mode production > stats.json",
@@ -142,7 +143,6 @@
142143
"@typescript-eslint/parser": "^5.35.1",
143144
"autoprefixer": "^10.4.8",
144145
"babel-loader": "^8.2.5",
145-
"canvas": "^2.10.1",
146146
"common-tags": "^1.8.2",
147147
"copy-webpack-plugin": "^11.0.0",
148148
"cross-env": "^7.0.3",
@@ -215,6 +215,7 @@
215215
"mobx-react": "^7.5.2",
216216
"nanoid": "^4.0.0",
217217
"object-hash": "^3.0.0",
218+
"patch-package": "^6.4.7",
218219
"query-string": "7.1.1",
219220
"rc-slider": "^10.0.1",
220221
"react": "^17.0.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
diff --git a/node_modules/react-data-grid/lib/bundle.js b/node_modules/react-data-grid/lib/bundle.js
2+
index d6d69ff..633ddaf 100644
3+
--- a/node_modules/react-data-grid/lib/bundle.js
4+
+++ b/node_modules/react-data-grid/lib/bundle.js
5+
@@ -1115,12 +1115,19 @@ function HeaderCell({
6+
const width = event.clientX + offset - currentTarget.getBoundingClientRect().left;
7+
8+
if (width > 0) {
9+
- onResize(column, width);
10+
+ // [CC] Call onResize with complete: false
11+
+ onResize(column, width, false);
12+
}
13+
}
14+
15+
function onPointerUp(event) {
16+
if (event.pointerId !== pointerId) return;
17+
+
18+
+ // [CC] Determine the width then call onResize with complete: true
19+
+ const width = event.clientX + offset - currentTarget.getBoundingClientRect().left;
20+
+ onResize(column, width, true);
21+
+ // [/CC]
22+
+
23+
window.removeEventListener('pointermove', onPointerMove);
24+
window.removeEventListener('pointerup', onPointerUp);
25+
}
26+
@@ -1887,13 +1894,20 @@ function DataGrid({
27+
28+
selectCell
29+
}));
30+
- const handleColumnResize = useCallback((column, width) => {
31+
+ // [CC] Add optional complete parameter, move onColumnResize() before setColumnWidths(),
32+
+ // and delete saved column width depending on value of onColumnResize()
33+
+ const handleColumnResize = useCallback((column, width, complete) => {
34+
+ const result = onColumnResize?.(column.idx, width, complete);
35+
setColumnWidths(columnWidths => {
36+
const newColumnWidths = new Map(columnWidths);
37+
- newColumnWidths.set(column.key, width);
38+
+ if (result) {
39+
+ newColumnWidths.delete(column.key);
40+
+ } else {
41+
+ newColumnWidths.set(column.key, width);
42+
+ }
43+
+ // [/CC]
44+
return newColumnWidths;
45+
});
46+
- onColumnResize == null ? void 0 : onColumnResize(column.idx, width);
47+
}, [onColumnResize]);
48+
const setDraggedOverRowIdx = useCallback(rowIdx => {
49+
setOverRowIdx(rowIdx);
50+
diff --git a/node_modules/react-data-grid/lib/index.d.ts b/node_modules/react-data-grid/lib/index.d.ts
51+
index e3d1398..b792226 100644
52+
--- a/node_modules/react-data-grid/lib/index.d.ts
53+
+++ b/node_modules/react-data-grid/lib/index.d.ts
54+
@@ -159,7 +159,8 @@ export declare interface DataGridProps<R, SR = unknown> extends SharedDivProps {
55+
/** Called when the grid is scrolled */
56+
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
57+
/** Called when a column is resized */
58+
- onColumnResize?: (idx: number, width: number) => void;
59+
+ // [CC] Add optional complete parameter
60+
+ onColumnResize?: (idx: number, width: number, complete?: boolean) => void;
61+
/** Function called whenever selected cell is changed */
62+
onSelectedCellChange?: (position: Position) => void;
63+
/**
64+
@@ -380,3 +381,4 @@ export declare function ToggleGroupFormatter<R, SR>({ groupKey, isExpanded, isCe
65+
export declare function ValueFormatter<R, SR>(props: FormatterProps<R, SR>): JSX.Element | null;
66+
67+
export { }
68+
+

src/components/tools/table-tool/table-tool.tsx

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { observer } from "mobx-react";
22
import { onSnapshot } from "mobx-state-tree";
3-
import React, { useCallback, useEffect, useRef, useState } from "react";
3+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import ReactDataGrid from "react-data-grid";
55
import { TableContentModelType } from "../../../models/tools/table/table-content";
66
import { exportTableContentAsJson } from "../../../models/tools/table/table-export";
@@ -39,6 +39,7 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
3939
// Gather data from the model
4040
const modelRef = useCurrent(model);
4141
const getContent = useCallback(() => modelRef.current.content as TableContentModelType, [modelRef]);
42+
const content = useMemo(() => getContent(), [getContent]);
4243
const metadata = getContent().metadata;
4344

4445
// Basic operations based on the model
@@ -47,8 +48,7 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
4748
} = useModelDataSet(model);
4849

4950
// Set up user specified columns and function to measure a column
50-
// TODO The user specified columns should be moved out of react and into MST
51-
const { userColumnWidths, measureColumnWidth } = useMeasureColumnWidth();
51+
const { measureColumnWidth, resizeColumn, resizeColumnWidth } = useMeasureColumnWidth({ content });
5252

5353
// Functions for determining the height of rows, including the header
5454
// These require knowledge of the column widths
@@ -85,7 +85,7 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
8585

8686
// The size of the title bar
8787
const { titleCellWidth, getTitleHeight } =
88-
useTitleSize({ readOnly, columns, measureColumnWidth, dataSet: dataSet.current });
88+
useTitleSize({ readOnly, columns, measureColumnWidth, dataSet: dataSet.current, rowChanges });
8989

9090
// A function to update the height of the tile based on the content size
9191
const heightRef = useCurrent(height);
@@ -108,7 +108,7 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
108108

109109
// A function to call when a column needs to change width
110110
const { onColumnResize } = useColumnResize({
111-
columns, userColumnWidths, requestRowHeight, triggerRowChange
111+
columns, content, requestRowHeight, resizeColumn, resizeColumnWidth, triggerRowChange
112112
});
113113
// Finishes setting up the controlsColumn with changeHandlers (which weren't defined when controlColumn was created)
114114
useControlsColumn({ controlsColumn, readOnly: !!readOnly, onAddColumn, onRemoveRows });
@@ -169,8 +169,8 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
169169
});
170170
}, [rows, rowHeight, headerHeight, getTitleHeight, getContent, modelRef, readOnly]);
171171
const exportContentAsTileJson = useCallback(() => {
172-
return exportTableContentAsJson(getContent().metadata, dataSet.current);
173-
}, [dataSet, getContent]);
172+
return exportTableContentAsJson(content.metadata, dataSet.current, content.columnWidth);
173+
}, [dataSet, content]);
174174
useToolApi({ content: getContent(), getTitle, getContentHeight, exportContentAsTileJson,
175175
onRegisterToolApi, onUnregisterToolApi });
176176

@@ -191,6 +191,13 @@ const TableToolComponent: React.FC<IToolTileProps> = observer(({
191191
return () => disposer();
192192
});
193193

194+
useEffect(() => {
195+
const disposer = onSnapshot(content.columnWidths, () => {
196+
triggerRowChange();
197+
});
198+
return () => disposer();
199+
});
200+
194201
const toolbarProps = useToolbarToolApi({ id: model.id, enabled: !readOnly, onRegisterToolApi, onUnregisterToolApi });
195202
return (
196203
<div className="table-tool">
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
11
import React, { useCallback } from "react";
2-
import { TColumn } from "./table-types";
2+
import { kMinColumnWidth, TColumn } from "./table-types";
3+
import { TableContentModelType } from "../../../models/tools/table/table-content";
34

45
interface IUseColumnResize {
56
columns: TColumn[];
6-
userColumnWidths: React.MutableRefObject<Record<string, number>>;
7+
content: TableContentModelType;
78
requestRowHeight: () => void;
9+
resizeColumn: React.MutableRefObject<string | undefined>;
10+
resizeColumnWidth: React.MutableRefObject<number | undefined>;
811
triggerRowChange: () => void;
912
}
1013
export const useColumnResize = ({
11-
columns, userColumnWidths, requestRowHeight, triggerRowChange
14+
columns, content, requestRowHeight, resizeColumn, resizeColumnWidth, triggerRowChange
1215
}: IUseColumnResize) => {
13-
14-
// This is called constantly as the user resizes the column. When we start saving the width to the model,
15-
// we'll only want to do so when the user ends adjusting the width.
16-
const onColumnResize = useCallback((idx: number, width: number) => {
17-
userColumnWidths.current[columns[idx].key] = width;
16+
// We had to modify react-data-grid to make this work.
17+
// We added the complete boolean, which is true when the user has finished modifying the column width (mouseup).
18+
// Additionally, we're returning true to indicate that rdg shouldn't remember the user's specified width, and instead
19+
// should always respect the width we send it (since we're remembering the user's width ourselves).
20+
const onColumnResize = useCallback((idx: number, width: number, complete: boolean) => {
21+
const attrId = columns[idx].key;
22+
const legalWidth = Math.max(kMinColumnWidth, width);
23+
if (complete) {
24+
// We're finished updating the width, so save it to the model
25+
content.setColumnWidth(attrId, legalWidth);
26+
resizeColumn.current = undefined;
27+
resizeColumnWidth.current = undefined;
28+
} else {
29+
// We're not finished updating the width, so just save it in react for now
30+
resizeColumn.current = attrId;
31+
resizeColumnWidth.current = legalWidth;
32+
}
1833
requestRowHeight();
1934
triggerRowChange(); // triggerRowChange is used because triggerColumnChange doesn't force a rerender
20-
}, [columns, userColumnWidths, requestRowHeight, triggerRowChange]);
35+
return true;
36+
}, [columns, content, requestRowHeight, resizeColumn, resizeColumnWidth, triggerRowChange]);
2137

2238
return { onColumnResize };
2339
};

src/components/tools/table-tool/use-columns-from-data-set.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const useColumnsFromDataSet = ({
6868
name: attr.name,
6969
key: attr.id,
7070
width,
71-
resizable: true,
71+
resizable: !readOnly,
7272
headerRenderer: ColumnHeaderCell,
7373
formatter: getCellFormatter(width, rowHeight),
7474
editor: !readOnly && !metadata.hasExpression(attr.id) ? CellTextEditor : undefined,
@@ -95,8 +95,8 @@ export const useColumnsFromDataSet = ({
9595
}
9696
columnChanges; // eslint-disable-line no-unused-expressions
9797
return cols;
98-
}, [attributes, headerHeight, rowHeight, RowLabelHeader, RowLabelFormatter, readOnly, columnChanges,
99-
controlsColumn, cellClasses, measureColumnWidth, metadata]);
98+
}, [attributes, rowHeight, RowLabelHeader, RowLabelFormatter, readOnly, columnChanges,
99+
ColumnHeaderCell, controlsColumn, cellClasses, measureColumnWidth, metadata]);
100100

101101
return { columns, controlsColumn, columnEditingName, handleSetColumnEditingName };
102102
};

src/components/tools/table-tool/use-data-set.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ interface IUseDataSet {
2828
rows: TRow[];
2929
changeHandlers: IContentChangeHandlers;
3030
columns: TColumn[];
31-
onColumnResize: (idx: number, width: number) => void;
31+
onColumnResize: (idx: number, width: number, complete: boolean) => void;
3232
}
3333
export const useDataSet = ({
3434
gridRef, model, dataSet, triggerColumnChange, triggerRowChange, readOnly, inputRowId, selectedCell, rows,
@@ -112,9 +112,10 @@ export const useDataSet = ({
112112
}
113113
}
114114
};
115-
const handleColumnResize = useCallback((idx: number, width: number) => {
116-
onColumnResize(idx, width);
115+
const handleColumnResize = useCallback((idx: number, width: number, complete?: boolean) => {
116+
const returnVal = onColumnResize(idx, width, complete || false);
117117
triggerColumnChange();
118+
return returnVal;
118119
}, [onColumnResize, triggerColumnChange]);
119120
return { hasLinkableRows, onColumnResize: handleColumnResize, onRowsChange, onSelectedCellChange};
120121
};
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
import { useCallback, useRef } from "react";
22
import { kMinColumnWidth } from "./table-types";
33
import { IAttribute } from "../../../models/data/attribute";
4+
import { TableContentModelType } from "../../../models/tools/table/table-content";
45

5-
export const useMeasureColumnWidth = () => {
6-
// In the future, these should come from the model rather than being saved in react land
7-
const userColumnWidths = useRef<Record<string, number>>({});
6+
interface IUseMeasureColumnWidth {
7+
content: TableContentModelType;
8+
}
9+
export const useMeasureColumnWidth = ({ content }: IUseMeasureColumnWidth) => {
10+
// The id of the column that is currently being modified
11+
const resizeColumn = useRef<string>();
12+
// The current width of the column being modified
13+
const resizeColumnWidth = useRef<number>();
814

915
const measureColumnWidth = useCallback((attr: IAttribute) => {
10-
return Math.max(kMinColumnWidth, userColumnWidths.current[attr.id] || 0);
11-
}, []);
16+
if (resizeColumn.current === attr.id && resizeColumnWidth.current !== undefined) {
17+
return resizeColumnWidth.current;
18+
} else {
19+
return content.columnWidth(attr.id) || kMinColumnWidth;
20+
}
21+
}, [content]);
1222

13-
return { userColumnWidths, measureColumnWidth };
23+
return { measureColumnWidth, resizeColumn, resizeColumnWidth };
1424
};

src/components/tools/table-tool/use-title-size.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ interface IProps {
1111
columns: TColumn[];
1212
dataSet: IDataSet;
1313
measureColumnWidth: (attr: IAttribute) => number;
14+
rowChanges: number;
1415
}
15-
export const useTitleSize = ({ readOnly, columns, dataSet, measureColumnWidth }: IProps) => {
16+
export const useTitleSize = ({ readOnly, columns, dataSet, measureColumnWidth, rowChanges }: IProps) => {
1617
const titleCellWidth = useMemo(() => {
1718
const columnWidth = (column: TColumn) => {
1819
if (!isDataColumn(column)) {
@@ -27,8 +28,9 @@ export const useTitleSize = ({ readOnly, columns, dataSet, measureColumnWidth }:
2728
(sum, col, i) => sum + (i ? columnWidth(col) : 0),
2829
1 - (readOnly ? 0 : kControlsColumnWidth));
2930
};
31+
rowChanges; // eslint-disable-line no-unused-expressions
3032
return getTitleCellWidthFromColumns();
31-
}, [readOnly, columns, dataSet, measureColumnWidth]);
33+
}, [readOnly, columns, dataSet, measureColumnWidth, rowChanges]);
3234

3335
const getTitleHeight = useCallback(() => {
3436
const font = `700 ${defaultFont}`;

src/models/document/document-content.test.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { IDropRowInfo } from "../../models/document/document-content";
77
import { cloneTileSnapshotWithoutId, IDragTileItem } from "../../models/tools/tool-tile";
88
import { TextContentModel } from "../tools/text/text-content";
99
import { IDocumentExportOptions } from "../tools/tool-content-info";
10+
import { kDefaultColumnWidth } from "../../components/tools/table-tool/table-types";
1011
import { safeJsonParse } from "../../utilities/js-utils";
1112
import placeholderImage from "../../assets/image_placeholder.png";
1213

@@ -112,7 +113,8 @@ describe("DocumentContentModel", () => {
112113
{ title: "Graph 1", content: { type: "Geometry", objects: [] } },
113114
{ content: { type: "Text", format: "html", text: ["<p></p>"] } }
114115
],
115-
{ title: "Table 1", content: { type: "Table", columns: [{ name: "x" }, { name: "y" }] } },
116+
{ title: "Table 1", content: { type: "Table",
117+
columns: [{ name: "x", width: kDefaultColumnWidth }, { name: "y", width: kDefaultColumnWidth }] } },
116118
{ title: "Drawing 1", content: { type: "Drawing", objects: [] } }
117119
]
118120
});
@@ -1030,8 +1032,8 @@ describe("DocumentContentModel -- move/copy tiles --", () => {
10301032
content: {
10311033
type: "Table",
10321034
columns: [
1033-
{ name: "x", values: [1, 2, 3] },
1034-
{ name: "y", values: [2, 4, 6] }
1035+
{ name: "x", width: kDefaultColumnWidth, values: [1, 2, 3] },
1036+
{ name: "y", width: kDefaultColumnWidth, values: [2, 4, 6] }
10351037
]
10361038
}
10371039
},

src/models/tools/table/table-content.test.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defaultTableContent, kTableToolID, TableContentModel, TableMetadataMode
22
import { TableContentTableImport } from "./table-import";
33
import { IDataSet } from "../../data/data-set";
44
import { kSerializedXKey } from "../../data/expression-utils";
5+
import { kDefaultColumnWidth } from "../../../components/tools/table-tool/table-types";
56

67
// mock Logger calls
78
jest.mock("../../../lib/logger", () => {
@@ -35,7 +36,7 @@ describe("TableContent", () => {
3536
expect(emptyTable.dataSet.cases.length).toBe(0);
3637
expect(emptyTable.isUserResizable).toBe(true);
3738

38-
const defaultTable = TableContentModel.create(defaultTableContent());
39+
const defaultTable = defaultTableContent();
3940
expect(defaultTable.type).toBe(kTableToolID);
4041
expect(defaultTable.isImported).toBe(true);
4142
expect(defaultTable.dataSet.attributes.length).toBe(2);
@@ -85,6 +86,33 @@ describe("TableContent", () => {
8586
expect(getCaseNoId(table.dataSet, 2)).toEqual({ xCol: "", yCol: "y3" });
8687
});
8788

89+
it("can import an authored table with column widths", () => {
90+
const colWidth = 200;
91+
const biggerColWidth = 500;
92+
const importData: TableContentTableImport = {
93+
type: "Table",
94+
name: "Table Title",
95+
columns: [
96+
{ name: "xCol", values: ["x1", "x2"] },
97+
{ name: "yCol", width: colWidth, values: ["y1", "y2", "y3"] }
98+
]
99+
};
100+
const table = TableContentModel.create(importData);
101+
expect(table.type).toBe(kTableToolID);
102+
const xCol = table.dataSet.attrFromName("xCol");
103+
expect(xCol).not.toBeUndefined();
104+
if (xCol) {
105+
expect(table.columnWidth(xCol.id)).toEqual(kDefaultColumnWidth);
106+
}
107+
const yCol = table.dataSet.attrFromName("yCol");
108+
expect(yCol).not.toBeUndefined();
109+
if (yCol) {
110+
expect(table.columnWidth(yCol.id)).toEqual(colWidth);
111+
table.setColumnWidth(yCol.id, biggerColWidth);
112+
expect(table.columnWidth(yCol.id)).toEqual(biggerColWidth);
113+
}
114+
});
115+
88116
// Table Remodel 8/9/2022
89117
// Loading expressions stopped working, and we ran out of time to fix it.
90118
// We hope to reimplement these tests sometime in the future.

0 commit comments

Comments
 (0)