Skip to content

Commit 06b95e9

Browse files
committed
fix: place ratings by coordinates, support any N×N matrix, report invalid data (B05)
1 parent 8bec0eb commit 06b95e9

14 files changed

Lines changed: 428 additions & 121 deletions

‎.ai/output/features/react-data-matrix-1.0.feature‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,33 @@ Feature: React Data Matrix 1.0
7878
When it imports the matrix component and its data types
7979
Then the types resolve without errors
8080

81-
@planned @B05
81+
@B05
8282
Scenario: Ratings are placed by their coordinates, not by list order
8383
Given matrix values supplied in any order
8484
When the matrix is rendered
8585
Then each rating appears at its own likelihood and consequence
8686

87+
@B05
88+
Scenario Outline: Matrices of any size render with aligned headings
89+
Given a <size> by <size> risk matrix
90+
When the matrix is rendered
91+
Then it shows <size> likelihood rows and <size> consequence columns
92+
And every rating sits under its own likelihood and consequence headings
93+
94+
Examples:
95+
| size |
96+
| 3 |
97+
| 4 |
98+
| 5 |
99+
| 6 |
100+
101+
@B05
102+
Scenario: Invalid ratings are reported instead of breaking the page
103+
Given a rating whose likelihood or consequence matches no heading
104+
When the matrix is rendered
105+
Then every valid rating is still shown
106+
And a clear message names the rating that could not be placed
107+
87108
@planned @B09
88109
Scenario: A developer reacts to a cell being chosen
89110
Given a matrix with a cell selection handler

‎lib/components/MatrixHeaders.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MatrixHeaderProps } from '../types/index.js';
1010

1111
const MatrixHeaders: FC<MatrixHeaderProps> = ({
1212
data,
13+
columns,
1314
hasInlineStyles = true,
1415
headerPrimaryUpper = true,
1516
thRowStyles = {},
@@ -38,24 +39,31 @@ const MatrixHeaders: FC<MatrixHeaderProps> = ({
3839
? capitaliseString(data?.primary_header_title)
3940
: data?.primary_header_title;
4041

42+
// The axis title sits over the middle column: two row-header columns plus the
43+
// columns before it on the left, the rest on the right (4 and 2 for a 5×5).
44+
const titleColumn = Math.floor((columns.length - 1) / 2);
45+
const columnsAfterTitle = columns.length - 1 - titleColumn;
46+
4147
return (
4248
<thead>
4349
<tr id="react-matrix-blank-headers-primary-title-row">
44-
<th headers="blank" colSpan={4}></th>
50+
<th headers="blank" colSpan={2 + titleColumn}></th>
4551
<th
4652
style={headerPrimaryTitleStyles}
4753
id="react-matrix-header-primary-title"
4854
>
4955
{headerPrimaryTitle}
5056
</th>
51-
<th headers="blank" colSpan={2}></th>
57+
{columnsAfterTitle > 0 && (
58+
<th headers="blank" colSpan={columnsAfterTitle}></th>
59+
)}
5260
</tr>
5361
<tr
5462
style={headerRowStyles}
5563
id={`react-matrix-dynamic-headers-row-${customHeaderRowIdValue}`}
5664
>
5765
<th headers="blank" colSpan={2}></th>
58-
{data.matrix_details.slice(0, data.matrix_size).map((column, index) => {
66+
{columns.map((column, index) => {
5967
return (
6068
<th
6169
scope="col"

‎lib/components/MatrixRows.tsx‎

Lines changed: 45 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ import {
55
getHeaderSubTitleStyles,
66
getHeaderPrimaryTitleStyles,
77
} from '../helpers/getStyles.js';
8-
import { groupObjectsByProp, capitaliseString } from '../utils/functions.js';
8+
import { capitaliseString } from '../utils/functions.js';
99
import TableData from './TableData.js';
10-
import { MatrixRowsProps, MatrixValue } from '../types/index.js';
10+
import { MatrixRowsProps } from '../types/index.js';
1111

1212
const MatrixRows: FC<MatrixRowsProps> = ({
1313
data,
14+
rows,
1415
rowPrimaryUpper = true,
1516
hasInlineStyles = true,
16-
reverseMatrixValues = true,
1717
trRowStyles = {},
1818
trTitleStyles = {},
1919
trSubTitleStyles = {},
@@ -23,15 +23,6 @@ const MatrixRows: FC<MatrixRowsProps> = ({
2323
customRowHeaderDynamicIdValue = '',
2424
customTableDataDynamicIdValue = '',
2525
}: MatrixRowsProps) => {
26-
const valuesArray = reverseMatrixValues
27-
? groupObjectsByProp(data.matrix_values, 'likelihood_descriptor')
28-
.reverse()
29-
.slice(0, data.matrix_size)
30-
: groupObjectsByProp(data.matrix_values, 'likelihood_descriptor').slice(
31-
0,
32-
data.matrix_size
33-
);
34-
3526
const tableRowStyles = getHeaderRowStyles(hasInlineStyles, trRowStyles);
3627
const tableRowHeaderTitleStyles = getHeaderTitleStyles(
3728
hasInlineStyles,
@@ -53,59 +44,53 @@ const MatrixRows: FC<MatrixRowsProps> = ({
5344
return (
5445
<tbody>
5546
<tr id="react-matrix-row-primary-title">
56-
<th scope="row" rowSpan={6} style={rowHeaderPrimaryTitleStyles}>
47+
<th
48+
scope="row"
49+
rowSpan={rows.length + 1}
50+
style={rowHeaderPrimaryTitleStyles}
51+
>
5752
{rowPrimaryTitle}
5853
</th>
5954
</tr>
60-
{valuesArray.map((row, index) => {
61-
const rowHeaderOrder = reverseMatrixValues
62-
? data.matrix_size - (index + 1)
63-
: index;
64-
65-
return (
66-
<tr
67-
style={tableRowStyles}
68-
key={`row-${index}`}
69-
id={`react-matrix-dynamic-rows-${
70-
data.matrix_details[rowHeaderOrder]?.row_header_title
71-
}-${data.matrix_details[rowHeaderOrder]?.row_header_sub_title}${
72-
!customRowDynamicIdValue ? '' : `-${customRowDynamicIdValue}`
55+
{rows.map(({ detail, cells }) => (
56+
<tr
57+
style={tableRowStyles}
58+
key={detail.id}
59+
id={`react-matrix-dynamic-rows-${detail.row_header_title}-${
60+
detail.row_header_sub_title
61+
}${!customRowDynamicIdValue ? '' : `-${customRowDynamicIdValue}`}`}
62+
>
63+
<th
64+
scope="row"
65+
style={tableRowHeaderTitleStyles}
66+
id={`react-matrix-dynamic-rows-${detail.row_header_title}-${
67+
detail.row_header_sub_title
68+
}${
69+
!customRowHeaderDynamicIdValue
70+
? ''
71+
: `-${customRowHeaderDynamicIdValue}`
7372
}`}
7473
>
75-
<th
76-
scope="row"
77-
style={tableRowHeaderTitleStyles}
78-
id={`react-matrix-dynamic-rows-${
79-
data.matrix_details[rowHeaderOrder]?.row_header_title
80-
}-${data.matrix_details[rowHeaderOrder]?.row_header_sub_title}${
81-
!customRowHeaderDynamicIdValue
82-
? ''
83-
: `-${customRowHeaderDynamicIdValue}`
84-
}`}
85-
>
86-
{data.matrix_details[rowHeaderOrder]?.row_header_title}
87-
<div style={tableRowHeaderSubTitleStyles}>
88-
{data.matrix_details[rowHeaderOrder]?.row_header_sub_title}
89-
</div>
90-
</th>
91-
{row
92-
.slice(0, data.matrix_size)
93-
.map((item: MatrixValue, index: number) => {
94-
return (
95-
<TableData
96-
data={item}
97-
tdStyles={tdStyles}
98-
key={`${item.id}-${index}`}
99-
hasInlineStyles={hasInlineStyles}
100-
customTableDataDynamicIdValue={
101-
customTableDataDynamicIdValue
102-
}
103-
/>
104-
);
105-
})}
106-
</tr>
107-
);
108-
})}
74+
{detail.row_header_title}
75+
<div style={tableRowHeaderSubTitleStyles}>
76+
{detail.row_header_sub_title}
77+
</div>
78+
</th>
79+
{cells.map((cell, index) =>
80+
cell ? (
81+
<TableData
82+
data={cell}
83+
tdStyles={tdStyles}
84+
key={cell.id}
85+
hasInlineStyles={hasInlineStyles}
86+
customTableDataDynamicIdValue={customTableDataDynamicIdValue}
87+
/>
88+
) : (
89+
<td key={`empty-${index}`} />
90+
)
91+
)}
92+
</tr>
93+
))}
10994
</tbody>
11095
);
11196
};

‎lib/grid.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { MatrixData, MatrixDetail, MatrixValue } from './types/index.js';
2+
3+
// Pure layout model: which value sits in which cell. Rows are keyed by
4+
// `MatrixDetail.likelihood` and columns by `MatrixDetail.consequence`; each value
5+
// is placed by its own coordinates, never by its position in `matrix_values`.
6+
// Problems are returned as `issues` so the caller decides how to report them.
7+
8+
export interface GridRow {
9+
detail: MatrixDetail;
10+
cells: (MatrixValue | null)[];
11+
}
12+
13+
export interface Grid {
14+
columns: MatrixDetail[];
15+
rows: GridRow[];
16+
issues: string[];
17+
}
18+
19+
export const buildGrid = (
20+
data: MatrixData,
21+
{ reverse }: { reverse: boolean }
22+
): Grid => {
23+
const details = data.matrix_details.slice(0, data.matrix_size);
24+
const rowIndex = new Map(details.map((detail, index) => [detail.likelihood, index]));
25+
const columnIndex = new Map(
26+
details.map((detail, index) => [detail.consequence, index])
27+
);
28+
const knownLikelihoods = new Set(data.matrix_details.map((d) => d.likelihood));
29+
const knownConsequences = new Set(data.matrix_details.map((d) => d.consequence));
30+
31+
const cells = details.map(() => details.map((): MatrixValue | null => null));
32+
const issues: string[] = [];
33+
34+
for (const value of data.matrix_values) {
35+
const row = rowIndex.get(value.likelihood_descriptor);
36+
const column = columnIndex.get(value.consequence_descriptor);
37+
const coordinate = `likelihood "${value.likelihood_descriptor}" × consequence ${value.consequence_descriptor}`;
38+
39+
if (row === undefined || column === undefined) {
40+
// Values for rows/columns beyond `matrix_size` are excluded on purpose;
41+
// only coordinates that match no detail at all are data errors.
42+
const known =
43+
knownLikelihoods.has(value.likelihood_descriptor) &&
44+
knownConsequences.has(value.consequence_descriptor);
45+
if (!known) {
46+
issues.push(
47+
`react-data-matrix: value ${value.id} (${coordinate}) matches no row or column and is not shown.`
48+
);
49+
}
50+
continue;
51+
}
52+
53+
const existing = cells[row][column];
54+
if (existing) {
55+
issues.push(
56+
`react-data-matrix: values ${existing.id} and ${value.id} share ${coordinate}; only ${existing.id} is shown.`
57+
);
58+
continue;
59+
}
60+
cells[row][column] = value;
61+
}
62+
63+
const rows = details.map((detail, index) => ({ detail, cells: cells[index] }));
64+
return { columns: details, rows: reverse ? rows.reverse() : rows, issues };
65+
};

‎lib/index.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { FC } from 'react';
1+
import { FC, useEffect, useMemo } from 'react';
22
import {
33
getTableStyles,
44
getTableBoarder,
55
getContainerStyles,
66
} from './helpers/getStyles.js';
7+
import { buildGrid } from './grid.js';
78
import MatrixHeaders from './components/MatrixHeaders.js';
89
import MatrixRows from './components/MatrixRows.js';
910
import { ReactMatrixProps } from './types/index.js';
@@ -41,6 +42,17 @@ const ReactMatrix: FC<ReactMatrixProps> = ({
4142
customRowHeaderDynamicIdValue = '',
4243
customTableDataDynamicIdValue = '',
4344
}) => {
45+
const grid = useMemo(
46+
() => buildGrid(data, { reverse: reverseMatrixValues }),
47+
[data, reverseMatrixValues]
48+
);
49+
50+
// Invalid data is reported, never thrown: one bad value must not take down
51+
// the host page.
52+
useEffect(() => {
53+
grid.issues.forEach((issue) => console.error(issue));
54+
}, [grid]);
55+
4456
const tableBorder = getTableBoarder(
4557
!!(contextHasInlineStyles && contextHasTableBorder)
4658
);
@@ -61,6 +73,7 @@ const ReactMatrix: FC<ReactMatrixProps> = ({
6173
<MatrixHeaders
6274
{...{
6375
data,
76+
columns: grid.columns,
6477
hasInlineStyles,
6578
headerPrimaryUpper,
6679
thRowStyles,
@@ -75,9 +88,9 @@ const ReactMatrix: FC<ReactMatrixProps> = ({
7588
<MatrixRows
7689
{...{
7790
data,
91+
rows: grid.rows,
7892
rowPrimaryUpper,
7993
hasInlineStyles,
80-
reverseMatrixValues,
8194
trRowStyles,
8295
trTitleStyles,
8396
trSubTitleStyles,

‎lib/types/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { GridRow } from '../grid.js';
2+
13
export interface MatrixDetail {
24
id: number;
35
position: number;
@@ -63,6 +65,7 @@ export interface ReactMatrixProps {
6365

6466
export interface MatrixHeaderProps {
6567
data: MatrixData;
68+
columns: MatrixDetail[];
6669
hasInlineStyles?: boolean | undefined;
6770
headerPrimaryUpper?: boolean;
6871
thRowStyles?: React.CSSProperties;
@@ -76,9 +79,9 @@ export interface MatrixHeaderProps {
7679

7780
export interface MatrixRowsProps {
7881
data: MatrixData;
82+
rows: GridRow[];
7983
rowPrimaryUpper?: boolean;
8084
hasInlineStyles?: boolean | undefined;
81-
reverseMatrixValues?: boolean;
8285
trRowStyles?: React.CSSProperties;
8386
trTitleStyles?: React.CSSProperties;
8487
trSubTitleStyles?: React.CSSProperties;

‎lib/utils/functions.ts‎

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,3 @@
1-
export const groupObjectsByProp = <T>(array: T[], prop: keyof T) => {
2-
const map = new Map<T[keyof T], T[]>(
3-
Array.from(array, (obj) => [obj[prop], []])
4-
);
5-
6-
array.forEach((obj) => {
7-
const key = obj[prop];
8-
if (key !== undefined) {
9-
const group = map.get(key);
10-
if (group) {
11-
group.push(obj);
12-
}
13-
}
14-
});
15-
16-
return Array.from(map.values());
17-
};
18-
19-
// capitalises first character of each word
20-
export const capitaliseFirstCharacter = (stringValue: string): string =>
21-
stringValue.replace(/\b([a-z\s])/g, (string) => string.toUpperCase());
22-
231
// capitalizes entire word
242
export const capitaliseString = (stringValue: string): string =>
253
stringValue.toUpperCase();
26-
27-
export const callAll =
28-
<T extends (...args: never[]) => void>(...fns: T[]) =>
29-
(...args: Parameters<T>) =>
30-
fns.forEach((fn) => fn && fn(...args));

0 commit comments

Comments
 (0)