-
Notifications
You must be signed in to change notification settings - Fork 9
/
CriteriaTable.tsx
307 lines (270 loc) · 10.8 KB
/
CriteriaTable.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import { Global } from "@emotion/react";
import { PivotTableChart } from "@mui/icons-material";
import { Box, Button, Tooltip, Typography } from "@mui/material";
import {
type MRT_ColumnDef,
type MRT_TableInstance,
MRT_ToggleFullScreenButton,
MRT_ToggleGlobalFilterButton,
MaterialReactTable,
} from "material-react-table";
import React, { useState } from "react";
import { errorWithData } from "@/common/errorHandling";
import { useSessionUser } from "@/web/common/hooks";
import { tableStyles } from "@/web/topic/components/CriteriaTable/CriteriaTable.styles";
import { EdgeCell } from "@/web/topic/components/CriteriaTable/EdgeCell";
import { NodeCell } from "@/web/topic/components/CriteriaTable/NodeCell";
import { SolutionTotalCell } from "@/web/topic/components/CriteriaTable/SolutionTotalCell";
import { TotalsHeaderCell } from "@/web/topic/components/CriteriaTable/TotalsHeaderCell";
import { tableZoomClasses } from "@/web/topic/components/CriteriaTable/tableZoom";
import { AddNodeButton } from "@/web/topic/components/Node/AddNodeButton";
import {
useCriterionSolutionEdges,
useDefaultNode,
useNodeChildren,
} from "@/web/topic/store/nodeHooks";
import { useDisplayScores } from "@/web/topic/store/scoreHooks";
import { useUserCanEditTopicData } from "@/web/topic/store/userHooks";
import { getConnectingEdge } from "@/web/topic/utils/edge";
import { Edge, Node } from "@/web/topic/utils/graph";
import { useGeneralFilter, useTableFilter } from "@/web/view/currentViewStore/filter";
import { getSelectedTradeoffNodes } from "@/web/view/utils/diagramFilter";
import { applyScoreFilter } from "@/web/view/utils/generalFilter";
interface RowData {
rowHeader: HeaderCell;
rowHeaderLabel: string;
cells: Cell[];
}
interface Cell {
data: Node | Edge | string; // string for totals row
// use function instead of prop because `MRT_ColumnDef` will calculate all possible `accessorKey`
// values, based on the RowData props, and `ReactNode` has too many possible recursing Children props
render: () => React.ReactNode;
}
interface HeaderCell extends Cell {
id: string;
label: string;
}
const buildNodeHeader = (node: Node): HeaderCell => {
return {
id: node.id,
label: node.data.label,
data: node,
render: () => <NodeCell node={node} />,
};
};
const buildEdgeCell = (edge: Edge): Cell => {
return {
data: edge,
render: () => <EdgeCell edge={edge} />,
};
};
const buildTotalsHeader = (): HeaderCell => {
return {
id: "totals",
label: "Solution Totals",
data: "totals",
render: () => <TotalsHeaderCell />,
};
};
const buildSolutionTotalCell = (solution: Node, problem: Node): Cell => {
return {
// Probably makes sense for `score` to be in `data`, but I'm not sure how to get that value
// without a hook in the Table that gets all of the scores, which would be annoying because
// then adjusting any table score would rerender the whole table.
// We aren't trying to sort by this score or anything yet so we can do that later if we want to.
data: "",
render: () => <SolutionTotalCell solution={solution} problem={problem} />,
};
};
const buildTableCells = (
problemNode: Node,
solutions: Node[],
criteria: Node[],
edges: Edge[],
): [[HeaderCell, ...HeaderCell[]], ...[HeaderCell, ...Cell[]][]] => {
const headerRow = [problemNode, ...solutions].map((node) => buildNodeHeader(node)) as [
HeaderCell,
...HeaderCell[],
];
const bodyRows = criteria.map(
(criterion) =>
[
buildNodeHeader(criterion),
...solutions.map((solution) => {
const edge = getConnectingEdge(criterion.id, solution.id, edges);
if (!edge) {
throw errorWithData(`No edge found between ${criterion.id} and ${solution.id}`, edges);
}
return buildEdgeCell(edge);
}),
] as [HeaderCell, ...Cell[]],
);
const totalsRow = [
buildTotalsHeader(),
...solutions.map((solution) => {
return buildSolutionTotalCell(solution, problemNode);
}),
] as [HeaderCell, ...Cell[]];
return [headerRow, ...bodyRows, totalsRow];
};
const buildTableDefs = (
tableData: [[HeaderCell, ...HeaderCell[]], ...[HeaderCell, ...Cell[]][]],
useSolutionsForColumns: boolean,
): { rowData: RowData[]; columnData: MRT_ColumnDef<RowData>[] } => {
const [headerRow, ...bodyRows] = tableData;
const rowData: RowData[] = bodyRows.map((row) => {
const [rowHeader, ...rowCells] = row;
return {
rowHeader: rowHeader,
rowHeaderLabel: rowHeader.label,
cells: rowCells,
};
});
const columnData: MRT_ColumnDef<RowData>[] = [
{
accessorKey: "rowHeaderLabel", // this determines how cols should sort/filter
header: useSolutionsForColumns ? "criteria" : "solutions",
Header: headerRow[0].render(),
enableHiding: false,
Cell: ({ row }) => row.original.rowHeader.render(),
},
...headerRow.slice(1).map((columnHeader, columnIndex) => {
return {
// Don't yet know what we'd want to sort/filter by for edges, so we're setting id instead
// of accessorKey
id: columnHeader.id,
header: columnHeader.label,
Header: columnHeader.render(),
enableColumnFilter: false,
Cell: ({ row }) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assume that every row has same number of cells as header row
return row.original.cells[columnIndex]!.render();
},
} as MRT_ColumnDef<RowData>;
}),
];
return { rowData, columnData };
};
/**
* Pseudocode:
* 1. create 2D array `tableData` like (each cell knows how it should render too):
* [
* [problem, solution1, solution2, solution3, ...],
* [criterion1, edge11, edge12, edge13, ...],
* [criterion2, edge21, edge22, edge23, ...],
* [criterion3, edge31, edge32, edge33, ...],
* ...
* ["totals", edgeX1 * criterion1, edge X2 * criterion2, edge X3 * criterion3, ...]
* ]
* 2. transpose the array, if transpose button is clicked
* 3. table-ify the array:
* a. first row turns into headers (`columnData`) that specify filter config and tell cells to render themselves
* b. subsequent rows turn into row objects (`rowData`) that know their row header details (for filtering)
*/
export const CriteriaTable = () => {
const [useSolutionsForColumns, setUseSolutionsForColumns] = useState<boolean>(true);
const { sessionUser } = useSessionUser();
const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username);
const tableFilter = useTableFilter();
const generalFilter = useGeneralFilter();
// if no problem is selected, show the criteria table for a fallback problem
const problemNode = useDefaultNode("problem", tableFilter.centralProblemId);
const nodeChildren = useNodeChildren(problemNode?.id);
const edges = useCriterionSolutionEdges(problemNode?.id);
const scores = useDisplayScores(nodeChildren.map((node) => node.id));
if (!problemNode)
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100%">
<Typography>Select a central problem node to view the tradeoff table</Typography>
</Box>
);
const solutions = nodeChildren.filter((child) => child.type === "solution");
const criteria = nodeChildren.filter((child) => child.type === "criterion");
const { selectedSolutions, selectedCriteria } = getSelectedTradeoffNodes(
solutions,
criteria,
tableFilter,
);
const filteredSolutions = applyScoreFilter(selectedSolutions, generalFilter, scores);
const filteredCriteria = applyScoreFilter(selectedCriteria, generalFilter, scores);
const tableData = buildTableCells(problemNode, filteredSolutions, filteredCriteria, edges);
const [headerRow, ..._bodyRows] = tableData;
const transposedTableData = useSolutionsForColumns
? tableData
: (headerRow.map((_, columnIndex) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assume that every row has same number of cells as header row
tableData.map((row) => row[columnIndex]!),
) as typeof tableData);
const { rowData, columnData } = buildTableDefs(transposedTableData, useSolutionsForColumns);
const ToolBarActions = (table: MRT_TableInstance<RowData>) => {
return (
<div className="pr-12">
<MRT_ToggleGlobalFilterButton table={table} />
{userCanEditTopicData && (
<>
<AddNodeButton
fromPartId={problemNode.id}
as="child"
toNodeType="solution"
relation={{ child: "solution", name: "addresses", parent: "problem" }}
/>
<AddNodeButton
fromPartId={problemNode.id}
as="child"
toNodeType="criterion"
relation={{ child: "criterion", name: "criterionFor", parent: "problem" }}
/>
</>
)}
<Tooltip title="Transpose Table">
<Button
size="small"
variant="contained"
color="neutral"
onClick={() => setUseSolutionsForColumns(!useSolutionsForColumns)}
>
<PivotTableChart />
</Button>
</Tooltip>
<MRT_ToggleFullScreenButton table={table} />
</div>
);
};
return (
<>
<Global styles={tableStyles} />
<MaterialReactTable
columns={columnData}
data={rowData}
enableColumnActions={false}
enablePagination={false}
enableBottomToolbar={false}
renderToolbarInternalActions={({ table }) => ToolBarActions(table)}
enableSorting={false}
enableStickyHeader={true}
// not very well documented in the library, but this drop zone takes up space for unknown reasons.
positionToolbarDropZone="none"
muiTablePaperProps={{
className: "criteria-table-paper",
}}
muiTableProps={{
className: tableZoomClasses,
}}
state={{
// have to set columnOrder because otherwise new columns are appended to the end, instead of before the last cell in the case of Solution Totals when table is transposed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- all columns should have an id or accessorKey set
columnOrder: columnData.map((col) => col.id ?? col.accessorKey!),
}}
initialState={{
// this won't work if the last row's header is just "totals" as a string
columnPinning: { left: ["rowHeaderLabel"] },
// columnOrder is defaulted to all cols, but then we'd have to maintain columnOrder when new cols are added
// defaulting this to empty should be fine until we want column reordering, then I think we'll have to maintain that
// manually, in order to have new columns included (new columns i.e. from transposing)
columnOrder: [],
}}
/>
</>
);
};