@adcops/autocore-react 3.3.106 → 3.3.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/assets/JogXLeft.d.ts +4 -0
  2. package/dist/assets/JogXLeft.d.ts.map +1 -0
  3. package/dist/assets/JogXLeft.js +1 -0
  4. package/dist/assets/JogXRight.d.ts +4 -0
  5. package/dist/assets/JogXRight.d.ts.map +1 -0
  6. package/dist/assets/JogXRight.js +1 -0
  7. package/dist/assets/index.d.ts +2 -2
  8. package/dist/assets/index.d.ts.map +1 -1
  9. package/dist/assets/index.js +1 -1
  10. package/dist/components/JogPanel.d.ts +29 -4
  11. package/dist/components/JogPanel.d.ts.map +1 -1
  12. package/dist/components/JogPanel.js +1 -1
  13. package/dist/components/ValueInput.d.ts.map +1 -1
  14. package/dist/components/ValueInput.js +1 -1
  15. package/dist/components/index.d.ts +2 -0
  16. package/dist/components/index.d.ts.map +1 -1
  17. package/dist/components/index.js +1 -1
  18. package/dist/components/network/StagedChangeBanner.d.ts.map +1 -1
  19. package/dist/components/network/StagedChangeBanner.js +1 -1
  20. package/dist/components/tis/ResultHistoryTable.css +12 -0
  21. package/dist/components/tis/ResultHistoryTable.d.ts +1 -0
  22. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  23. package/dist/components/tis/ResultHistoryTable.js +1 -1
  24. package/dist/components/tis/SampleSummaryPanel.d.ts +23 -0
  25. package/dist/components/tis/SampleSummaryPanel.d.ts.map +1 -0
  26. package/dist/components/tis/SampleSummaryPanel.js +1 -0
  27. package/dist/components/tis/ScienceTable.css +87 -0
  28. package/dist/components/tis/ScienceTable.d.ts +35 -0
  29. package/dist/components/tis/ScienceTable.d.ts.map +1 -0
  30. package/dist/components/tis/ScienceTable.js +1 -0
  31. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  32. package/dist/components/tis/TestDataView.js +1 -1
  33. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  34. package/dist/components/tis/TestSetupForm.js +1 -1
  35. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  36. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  37. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +6 -0
  38. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  39. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  40. package/dist/components/tis-editor/types.d.ts +15 -0
  41. package/dist/components/tis-editor/types.d.ts.map +1 -1
  42. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  43. package/dist/components/tis-editor/validation.js +1 -1
  44. package/package.json +1 -1
  45. package/src/assets/{JogXNeg.tsx → JogXLeft.tsx} +6 -3
  46. package/src/assets/{JogXPos.tsx → JogXRight.tsx} +6 -3
  47. package/src/assets/index.ts +2 -2
  48. package/src/components/JogPanel.tsx +64 -59
  49. package/src/components/ValueInput.tsx +10 -3
  50. package/src/components/index.ts +3 -0
  51. package/src/components/network/StagedChangeBanner.tsx +22 -3
  52. package/src/components/tis/ResultHistoryTable.css +12 -0
  53. package/src/components/tis/ResultHistoryTable.tsx +76 -4
  54. package/src/components/tis/SampleSummaryPanel.tsx +171 -0
  55. package/src/components/tis/ScienceTable.css +87 -0
  56. package/src/components/tis/ScienceTable.tsx +114 -0
  57. package/src/components/tis/TestDataView.tsx +92 -51
  58. package/src/components/tis/TestSetupForm.tsx +18 -2
  59. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +18 -1
  60. package/src/components/tis-editor/editor/TestFieldDialog.tsx +82 -1
  61. package/src/components/tis-editor/types.ts +17 -0
  62. package/src/components/tis-editor/validation.ts +35 -0
  63. package/dist/assets/JogXNeg.d.ts +0 -4
  64. package/dist/assets/JogXNeg.d.ts.map +0 -1
  65. package/dist/assets/JogXNeg.js +0 -1
  66. package/dist/assets/JogXPos.d.ts +0 -4
  67. package/dist/assets/JogXPos.d.ts.map +0 -1
  68. package/dist/assets/JogXPos.js +0 -1
@@ -108,10 +108,17 @@ export const ValueInput: React.FC<ValueInputProps> = ({
108
108
  }
109
109
  }, [value, editing]);
110
110
 
111
+ // Treat the bounds as an unordered interval: an author may write the range
112
+ // in "magnitude" order (e.g. min:-50, max:-1000 for a compressive load),
113
+ // which is numerically inverted. Without this, min > max yields an empty
114
+ // valid set and the field rejects every value, including its own default.
115
+ const lo = min !== undefined && max !== undefined ? Math.min(min, max) : min;
116
+ const hi = min !== undefined && max !== undefined ? Math.max(min, max) : max;
117
+
111
118
  const validate = (val: number | null): boolean => {
112
119
  if (val === null || Number.isNaN(val)) return false;
113
- if (min !== undefined && val < min) return false;
114
- if (max !== undefined && val > max) return false;
120
+ if (lo !== undefined && val < lo) return false;
121
+ if (hi !== undefined && val > hi) return false;
115
122
  return true;
116
123
  };
117
124
 
@@ -148,7 +155,7 @@ export const ValueInput: React.FC<ValueInputProps> = ({
148
155
  };
149
156
 
150
157
  const isLabelDefined = label !== undefined && label !== null && label !== '';
151
- const allowNegative = min === undefined || min < 0;
158
+ const allowNegative = lo === undefined || lo < 0;
152
159
 
153
160
  return (
154
161
  <div>
@@ -45,6 +45,9 @@ export type { TestMethodDialogProps } from './tis/TestMethodDialog';
45
45
  export { ResultHistoryTable } from './tis/ResultHistoryTable';
46
46
  export type { ResultHistoryTableProps } from './tis/ResultHistoryTable';
47
47
 
48
+ export { SampleSummaryPanel } from './tis/SampleSummaryPanel';
49
+ export type { SampleSummaryPanelProps } from './tis/SampleSummaryPanel';
50
+
48
51
  export { TestDataView } from './tis/TestDataView';
49
52
  export type { TestDataViewProps, ChartAxis, ChartSeries, ChartView, RawDataShape } from './tis/TestDataView';
50
53
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useState } from 'react';
14
+ import { createPortal } from 'react-dom';
14
15
  import { Button } from 'primereact/button';
15
16
  import { useNetwork } from './NetworkProvider';
16
17
 
@@ -38,10 +39,23 @@ export const StagedChangeBanner: React.FC<StagedChangeBannerProps> = ({ style })
38
39
  const total = Math.max(1, staged.revert_in_seconds);
39
40
  const pctRemaining = Math.max(0, Math.min(100, (remainingSec / total) * 100));
40
41
 
42
+ // NOTE: the banner is rendered through a portal into <body> (see the
43
+ // createPortal call below) and positioned `fixed`, not `sticky`. That is
44
+ // deliberate: host apps commonly wrap their content in a
45
+ // `position: fixed`/`transform`ed shell (which establishes a stacking
46
+ // context) and open configuration screens as PrimeReact *modal* dialogs.
47
+ // A modal Dialog portals its mask to <body> at z-index ~1100+, so a
48
+ // banner living inside the app shell — no matter how high its own
49
+ // z-index — renders *beneath* the modal and becomes unreachable
50
+ // ("press confirm on a popup that is hidden"). Portaling to <body> puts
51
+ // the banner in the root stacking context alongside the modal, where a
52
+ // z-index above the PrimeReact modal range actually wins.
41
53
  const containerStyle: React.CSSProperties = {
42
- position: 'sticky',
54
+ position: 'fixed',
43
55
  top: 0,
44
- zIndex: 1000,
56
+ left: 0,
57
+ right: 0,
58
+ zIndex: 2000,
45
59
  padding: '0.75rem 1rem',
46
60
  background: remainingSec <= 10 ? '#7f1d1d' : '#78350f',
47
61
  color: 'white',
@@ -50,7 +64,7 @@ export const StagedChangeBanner: React.FC<StagedChangeBannerProps> = ({ style })
50
64
  ...style,
51
65
  };
52
66
 
53
- return (
67
+ const banner = (
54
68
  <div style={containerStyle} role="alertdialog" aria-live="polite">
55
69
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
56
70
  <div style={{ minWidth: 0 }}>
@@ -96,6 +110,11 @@ export const StagedChangeBanner: React.FC<StagedChangeBannerProps> = ({ style })
96
110
  </div>
97
111
  </div>
98
112
  );
113
+
114
+ // Guard for non-DOM environments (SSR/tests); render inline if there is
115
+ // no document to portal into.
116
+ if (typeof document === 'undefined' || !document.body) return banner;
117
+ return createPortal(banner, document.body);
99
118
  };
100
119
 
101
120
  export default StagedChangeBanner;
@@ -0,0 +1,12 @@
1
+ /* Runs excluded from cross-test aggregation are kept visible but dimmed and
2
+ struck through, so an operator can see at a glance which rows don't feed
3
+ the avg/min/max/stddev/count results for their sample group. */
4
+ .tis-row-excluded > td {
5
+ opacity: 0.55;
6
+ text-decoration: line-through;
7
+ }
8
+ /* Keep the action buttons (Include / Download) legible and clickable. */
9
+ .tis-row-excluded > td .p-button {
10
+ opacity: 1;
11
+ text-decoration: none;
12
+ }
@@ -2,10 +2,12 @@ import React, { useState, useEffect, useContext } from 'react';
2
2
  import { DataTable } from 'primereact/datatable';
3
3
  import { Column } from 'primereact/column';
4
4
  import { Button } from 'primereact/button';
5
- import { Tag } from 'primereact/tag';
5
+ import { Badge } from 'primereact/badge';
6
6
  import { EventEmitterContext } from '../../core/EventEmitterContext';
7
7
  import { MessageType } from '../../hub/CommandMessage';
8
8
  import { useTis } from './TisProvider';
9
+ import { SampleSummaryPanel } from './SampleSummaryPanel';
10
+ import './ResultHistoryTable.css';
9
11
 
10
12
  export interface ResultHistoryTableProps {
11
13
  /**
@@ -72,6 +74,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
72
74
  const [loading, setLoading] = useState(false);
73
75
  const [downloading, setDownloading] = useState<InFlight | null>(null);
74
76
  const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
77
+ const [excludingRunId, setExcludingRunId] = useState<string | null>(null);
75
78
  const { invoke } = useContext(EventEmitterContext);
76
79
 
77
80
  const loadTests = async () => {
@@ -169,6 +172,34 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
169
172
  }
170
173
  };
171
174
 
175
+ // Toggle a run's inclusion in cross-test aggregation. Excluded runs are
176
+ // kept on disk (not deleted) but dropped from avg/min/max/stddev/count
177
+ // results for their sample group. The server recomputes + rebroadcasts
178
+ // the sample summary on success.
179
+ const handleToggleExclude = async (rowData: any) => {
180
+ const runId = rowData?.run_id;
181
+ const rowMethodId = rowData?.method_id ?? methodId;
182
+ if (!runId || !rowMethodId || !projectId) return;
183
+ const nextExcluded = rowData?.excluded !== true;
184
+ setExcludingRunId(runId);
185
+ try {
186
+ const resp: any = await invoke('tis.set_test_excluded' as any, MessageType.Request, {
187
+ project_id: projectId, method_id: rowMethodId, run_id: runId, excluded: nextExcluded,
188
+ } as any);
189
+ if (!resp?.success) {
190
+ alert(`Failed to ${nextExcluded ? 'exclude' : 'include'} ${runId}` +
191
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
192
+ return;
193
+ }
194
+ await loadTests();
195
+ } catch (err) {
196
+ console.error('Failed to toggle exclusion', err);
197
+ alert(`Exclude toggle failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ } finally {
199
+ setExcludingRunId(null);
200
+ }
201
+ };
202
+
172
203
  const handleProjectDownload = async (kind: ProjectDownloadKind) => {
173
204
  if (!projectId) return;
174
205
  const topic = kind === 'report' ? 'tis.export_project_csv'
@@ -284,6 +315,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
284
315
  </div>
285
316
  </div>
286
317
 
318
+ {/* Per-sample cross-test rollup. Renders nothing unless a method
319
+ declares aggregate results_fields, so projects without the
320
+ feature see the history table unchanged. */}
321
+ <SampleSummaryPanel projectId={projectId} methodId={methodId} />
322
+
287
323
  <DataTable
288
324
  value={tests}
289
325
  loading={loading}
@@ -294,6 +330,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
294
330
  scrollHeight="flex"
295
331
  tableStyle={{ minWidth: 0 }}
296
332
  style={{ width: '100%' }}
333
+ rowClassName={(rowData: any) => (rowData?.excluded === true ? 'tis-row-excluded' : '')}
297
334
  selectionMode="single"
298
335
  onSelectionChange={(e: any) => {
299
336
  const row = e.value;
@@ -327,13 +364,48 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
327
364
  style={{ minWidth: '12rem' }}
328
365
  />
329
366
  <Column field="method_id" header="Test Method" sortable style={{ minWidth: '10rem' }} />
330
- <Column field="run_id" header="Run ID" sortable style={{ minWidth: '12rem' }} />
367
+ {/* Run ID dropped from the table — it's an internal handle
368
+ (the row's sample_id + date already identify the run for a
369
+ human), and its 12rem column was the main reason the table
370
+ overflowed in portrait. Still carried on the row data and
371
+ used for selection + downloads. */}
331
372
  <Column header="Status" field="status" sortable
332
373
  body={(rowData) => {
333
374
  const s = statusOf(rowData);
334
- return <Tag value={s.label} severity={s.severity} />;
375
+ return <Badge value={s.label} severity={s.severity} />;
376
+ }}
377
+ style={{ minWidth: '6rem' }}
378
+ />
379
+ {/* Inclusion shown as a single checkmark / eye-slash toggle so
380
+ the column stays narrow. Green check = counted in cross-test
381
+ results; amber eye-slash = excluded. Click toggles. */}
382
+ <Column
383
+ header="Incl."
384
+ align="center"
385
+ alignHeader="center"
386
+ style={{ width: '4.5rem' }}
387
+ body={(rowData) => {
388
+ const isExcluded = rowData?.excluded === true;
389
+ const isBusy = excludingRunId === rowData.run_id;
390
+ return (
391
+ <Button
392
+ icon={isBusy ? 'pi pi-spin pi-spinner'
393
+ : (isExcluded ? 'pi pi-eye-slash' : 'pi pi-check')}
394
+ rounded
395
+ text
396
+ size="small"
397
+ severity={isExcluded ? 'warning' : 'success'}
398
+ disabled={excludingRunId !== null}
399
+ onClick={() => handleToggleExclude(rowData)}
400
+ aria-label={isExcluded ? 'Excluded — click to include'
401
+ : 'Included — click to exclude'}
402
+ tooltip={isExcluded
403
+ ? 'Excluded from cross-test results — click to include'
404
+ : 'Included in cross-test results — click to exclude'}
405
+ tooltipOptions={{ position: 'left' }}
406
+ />
407
+ );
335
408
  }}
336
- style={{ minWidth: '8rem' }}
337
409
  />
338
410
  <Column
339
411
  header="Download"
@@ -0,0 +1,171 @@
1
+ import React, { useState, useEffect, useContext } from 'react';
2
+ import { DataTable } from 'primereact/datatable';
3
+ import { Column } from 'primereact/column';
4
+ import { Dropdown } from 'primereact/dropdown';
5
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
6
+ import { MessageType } from '../../hub/CommandMessage';
7
+ import { useTis } from './TisProvider';
8
+
9
+ /**
10
+ * Per-method cross-test rollup, one row per sample_id.
11
+ *
12
+ * Distinct from the per-run results overlay in <TestDataView>: this panel
13
+ * answers "across every run of sample X, what's the avg/min/max/stddev/count?"
14
+ * — the question operators ask when a sample is run repeatedly. Backed by
15
+ * `tis.sample_summaries`; refreshes on the server's `tis.sample_summary_updated`
16
+ * broadcast (a sibling finishing or an exclusion toggle).
17
+ *
18
+ * Renders nothing unless some method in the project declares aggregate
19
+ * results_fields. When `methodId` is omitted and more than one method has
20
+ * aggregates, a small method picker is shown.
21
+ */
22
+ export interface SampleSummaryPanelProps {
23
+ /** Defaults to the active project from <TisProvider>. */
24
+ projectId?: string;
25
+ /** Lock the panel to one method. When omitted, the user picks among
26
+ * methods that declare aggregate results_fields. */
27
+ methodId?: string;
28
+ }
29
+
30
+ interface SummaryRow {
31
+ sample_id: string;
32
+ summary: Record<string, unknown>;
33
+ included_count: number;
34
+ excluded_count: number;
35
+ }
36
+
37
+ interface AggColumn {
38
+ name: string;
39
+ header: string;
40
+ scale?: number;
41
+ }
42
+
43
+ export const SampleSummaryPanel: React.FC<SampleSummaryPanelProps> = (props) => {
44
+ const tis = useTis();
45
+ const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
46
+ const projectId = props.projectId ?? tis.selection.projectId ?? undefined;
47
+
48
+ // Methods that actually declare an aggregate results_field — the only
49
+ // ones worth showing here.
50
+ const methodsWithAggregates = React.useMemo(() => {
51
+ return Object.entries(tis.schemas)
52
+ .filter(([, m]) => ((m?.results_fields ?? []) as any[]).some((f) => f?.aggregate))
53
+ .map(([id]) => id);
54
+ }, [tis.schemas]);
55
+
56
+ const [picked, setPicked] = useState<string | undefined>(undefined);
57
+ const methodId = props.methodId ?? picked ?? methodsWithAggregates[0];
58
+
59
+ // Keep the local pick valid as schemas load / change.
60
+ useEffect(() => {
61
+ if (props.methodId) return;
62
+ if (!picked || !methodsWithAggregates.includes(picked)) {
63
+ setPicked(methodsWithAggregates[0]);
64
+ }
65
+ }, [props.methodId, picked, methodsWithAggregates]);
66
+
67
+ const [rows, setRows] = useState<SummaryRow[]>([]);
68
+ const [loading, setLoading] = useState(false);
69
+
70
+ // Columns come from the schema's aggregate results_fields (label + units),
71
+ // so empty-but-declared columns still show with a blank cell.
72
+ const aggColumns: AggColumn[] = React.useMemo(() => {
73
+ const schema = methodId ? (tis.schemas[methodId] as any) : undefined;
74
+ const fields: any[] = (schema?.results_fields ?? []).filter((f: any) => f?.aggregate);
75
+ return fields.map((f) => ({
76
+ name: f.name,
77
+ header: (f.label || f.name) + (f.units ? ` [${f.units}]` : ''),
78
+ scale: typeof f.scale === 'number' ? f.scale : undefined,
79
+ }));
80
+ }, [tis.schemas, methodId]);
81
+
82
+ useEffect(() => {
83
+ if (!projectId || !methodId) { setRows([]); return; }
84
+ let cancelled = false;
85
+ const load = async () => {
86
+ setLoading(true);
87
+ try {
88
+ const resp: any = await invoke('tis.sample_summaries' as any, MessageType.Request,
89
+ { project_id: projectId, method_id: methodId } as any);
90
+ if (!cancelled && resp?.success) setRows(resp.data?.summaries ?? []);
91
+ } catch (err) {
92
+ console.error('Failed to load sample summaries', err);
93
+ } finally {
94
+ if (!cancelled) setLoading(false);
95
+ }
96
+ };
97
+ load();
98
+ // Any rollup change for this project+method refreshes the whole panel
99
+ // (cheap; also picks up brand-new sample groups the broadcast can't
100
+ // patch in place). Also follow the live run lifecycle like the table.
101
+ const onSummary = (payload: any) => {
102
+ if (payload?.project_id === projectId && payload?.method_id === methodId) load();
103
+ };
104
+ const id = subscribe('tis.sample_summary_updated', onSummary);
105
+ return () => { cancelled = true; unsubscribe(id); };
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [projectId, methodId, tis.state.activeRunId, tis.state.active]);
108
+
109
+ // Feature not configured for this project → render nothing.
110
+ if (methodsWithAggregates.length === 0) return null;
111
+
112
+ const fmt = (v: unknown, scale?: number): string => {
113
+ if (v === null || v === undefined) return '—';
114
+ if (typeof v === 'number') {
115
+ if (!Number.isFinite(v)) return String(v);
116
+ const scaled = typeof scale === 'number' && scale !== 0 ? v * scale : v;
117
+ return Number.parseFloat(scaled.toPrecision(6)).toString();
118
+ }
119
+ return String(v);
120
+ };
121
+
122
+ return (
123
+ <div style={{ width: '100%', maxWidth: '100%', overflow: 'hidden', boxSizing: 'border-box', marginBottom: '1rem' }}>
124
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', gap: '0.5rem', flexWrap: 'wrap' }}>
125
+ <h3 style={{ margin: 0 }}>Sample Summary</h3>
126
+ {!props.methodId && methodsWithAggregates.length > 1 && (
127
+ <Dropdown
128
+ value={methodId}
129
+ options={methodsWithAggregates.map((id) => ({ label: id, value: id }))}
130
+ onChange={(e) => setPicked(e.value)}
131
+ style={{ minWidth: '12rem' }}
132
+ />
133
+ )}
134
+ </div>
135
+ <DataTable
136
+ value={rows}
137
+ loading={loading}
138
+ dataKey="sample_id"
139
+ emptyMessage="No samples yet."
140
+ scrollable
141
+ tableStyle={{ minWidth: 0 }}
142
+ style={{ width: '100%' }}
143
+ size="small"
144
+ stripedRows
145
+ >
146
+ <Column field="sample_id" header="Sample ID" sortable style={{ minWidth: '8rem' }} />
147
+ {aggColumns.map((c) => (
148
+ <Column
149
+ key={c.name}
150
+ header={c.header}
151
+ bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
152
+ style={{ minWidth: '7rem' }}
153
+ body={(row: SummaryRow) => fmt(row.summary?.[c.name], c.scale)}
154
+ />
155
+ ))}
156
+ <Column
157
+ header="# Tests"
158
+ bodyStyle={{ textAlign: 'right' }}
159
+ style={{ width: '6rem' }}
160
+ body={(row: SummaryRow) => row.included_count}
161
+ />
162
+ <Column
163
+ header="Excluded"
164
+ bodyStyle={{ textAlign: 'right' }}
165
+ style={{ width: '6rem' }}
166
+ body={(row: SummaryRow) => (row.excluded_count > 0 ? row.excluded_count : '—')}
167
+ />
168
+ </DataTable>
169
+ </div>
170
+ );
171
+ };
@@ -0,0 +1,87 @@
1
+ /*
2
+ * ScienceTable — "instrument report" styling for cycle/results tables.
3
+ *
4
+ * Every rule is scoped under `.p-datatable.ac-science-table` (both classes on
5
+ * the DataTable root) so it out-specifies the PrimeReact theme's
6
+ * `.p-datatable ...` rules and cannot bleed onto any other table. The palette
7
+ * is a fixed light "lab report" look (dark headers, cream cells) independent
8
+ * of the app theme — that IS the requested Bluehill appearance.
9
+ */
10
+
11
+ .ac-science-table-wrap {
12
+ border: 1px solid #2b2b2b;
13
+ border-radius: 4px;
14
+ overflow: hidden;
15
+ background: #f7f4e9;
16
+ }
17
+
18
+ /* Dark title bar with a centered caption, like "Results 1". */
19
+ .ac-science-table-title {
20
+ background: #2b2b2b;
21
+ color: #ffffff;
22
+ text-align: center;
23
+ font-weight: 600;
24
+ letter-spacing: 0.02em;
25
+ padding: 0.4rem 0.75rem;
26
+ font-size: 0.95rem;
27
+ }
28
+
29
+ /* Horizontal scroll for wide results rows without pushing the page sideways. */
30
+ .ac-science-table-scroll {
31
+ overflow-x: auto;
32
+ }
33
+
34
+ /* ---- Column header band -------------------------------------------------- */
35
+ .p-datatable.ac-science-table .p-datatable-thead > tr > th {
36
+ background: #3d3d3d;
37
+ color: #ffffff;
38
+ text-align: center;
39
+ border: 1px solid #2b2b2b;
40
+ border-top: none;
41
+ padding: 0.35rem 0.6rem;
42
+ font-weight: 600;
43
+ vertical-align: middle;
44
+ }
45
+ .p-datatable.ac-science-table .p-datatable-thead > tr > th .p-column-header-content {
46
+ justify-content: center;
47
+ }
48
+
49
+ .ac-sci-th {
50
+ display: flex;
51
+ flex-direction: column;
52
+ align-items: center;
53
+ line-height: 1.15;
54
+ }
55
+ .ac-sci-th-unit {
56
+ font-weight: 400;
57
+ font-size: 0.8em;
58
+ opacity: 0.85;
59
+ }
60
+
61
+ /* ---- Data cells: cream, dark text, thin gridlines, tabular figures ------- */
62
+ .p-datatable.ac-science-table .p-datatable-tbody > tr {
63
+ background: #f7f4e9;
64
+ color: #1a1a1a;
65
+ }
66
+ .p-datatable.ac-science-table .p-datatable-tbody > tr > td {
67
+ background: #f7f4e9;
68
+ color: #1a1a1a;
69
+ text-align: center;
70
+ border: 1px solid #d9d2b8;
71
+ padding: 0.3rem 0.6rem;
72
+ font-variant-numeric: tabular-nums;
73
+ }
74
+ /* Faint zebra banding keeps long cycle lists readable on the cream base. */
75
+ .p-datatable.ac-science-table .p-datatable-tbody > tr:nth-child(even) > td {
76
+ background: #efe9d4;
77
+ }
78
+
79
+ /* ---- Row-index column: dark, matching the header band -------------------- */
80
+ .p-datatable.ac-science-table .p-datatable-thead > tr > th.ac-sci-index,
81
+ .p-datatable.ac-science-table .p-datatable-tbody > tr > td.ac-sci-index {
82
+ background: #3d3d3d;
83
+ color: #ffffff;
84
+ font-weight: 600;
85
+ text-align: center;
86
+ border: 1px solid #2b2b2b;
87
+ }
@@ -0,0 +1,114 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * ScienceTable — a presentational wrapper around PrimeReact's DataTable that
5
+ * gives cycle-data and results tables the crisp, "instrument report" look of
6
+ * a materials-testing readout (à la Instron Bluehill): a dark title bar, a
7
+ * dark column-header band with the unit in brackets on a second line, a dark
8
+ * row-index column, and pale data cells with thin gridlines and centered,
9
+ * tabular-aligned values.
10
+ *
11
+ * Purely visual — it takes rows + a column spec and renders them; it holds no
12
+ * data-fetching or business logic. Styling lives in ScienceTable.css, scoped
13
+ * to `.p-datatable.ac-science-table` so it never leaks onto other DataTables.
14
+ */
15
+
16
+ import React from 'react';
17
+ import { DataTable } from 'primereact/datatable';
18
+ import { Column } from 'primereact/column';
19
+ import type { ColumnBodyOptions } from 'primereact/column';
20
+ import './ScienceTable.css';
21
+
22
+ export interface ScienceColumn {
23
+ /** Row property to read. */
24
+ field: string;
25
+ /** Header label (unit rendered separately in brackets). */
26
+ label: string;
27
+ /** Optional unit, shown as `[unit]` on a second header line. */
28
+ units?: string;
29
+ /** Cell text alignment. Default 'center' (report style). */
30
+ align?: 'left' | 'center' | 'right';
31
+ /** Optional custom cell renderer (e.g. numeric formatting). */
32
+ body?: (row: any) => React.ReactNode;
33
+ /** Optional per-column style (e.g. minWidth). */
34
+ style?: React.CSSProperties;
35
+ }
36
+
37
+ export interface ScienceTableProps {
38
+ /** Title shown in the dark bar above the table. Omit for no bar. */
39
+ title?: React.ReactNode;
40
+ columns: ScienceColumn[];
41
+ rows: any[];
42
+ /** Leading 1-based row-index column, like the Bluehill "1". Default true. */
43
+ showIndex?: boolean;
44
+ /** Header for the index column. Default "#". */
45
+ indexHeader?: string;
46
+ emptyMessage?: string;
47
+ /** Enable virtual scrolling; pair with `scrollHeight` for large runs. */
48
+ scrollable?: boolean;
49
+ scrollHeight?: string;
50
+ /** Virtual-scroll row height in px. Default 38. */
51
+ virtualItemSize?: number;
52
+ }
53
+
54
+ const HeaderCell: React.FC<{ column: ScienceColumn }> = ({ column }) => (
55
+ <div className="ac-sci-th">
56
+ <span className="ac-sci-th-name">{column.label}</span>
57
+ {column.units ? <span className="ac-sci-th-unit">[{column.units}]</span> : null}
58
+ </div>
59
+ );
60
+
61
+ export const ScienceTable: React.FC<ScienceTableProps> = ({
62
+ title,
63
+ columns,
64
+ rows,
65
+ showIndex = true,
66
+ indexHeader = '#',
67
+ emptyMessage = 'No data.',
68
+ scrollable = false,
69
+ scrollHeight,
70
+ virtualItemSize = 38,
71
+ }) => {
72
+ const virtual = scrollable && !!scrollHeight;
73
+ return (
74
+ <div className="ac-science-table-wrap">
75
+ {title != null && <div className="ac-science-table-title">{title}</div>}
76
+ <div className="ac-science-table-scroll">
77
+ <DataTable
78
+ className="ac-science-table"
79
+ value={rows}
80
+ scrollable={scrollable}
81
+ scrollHeight={scrollHeight}
82
+ virtualScrollerOptions={virtual ? { itemSize: virtualItemSize } : undefined}
83
+ emptyMessage={emptyMessage}
84
+ >
85
+ {showIndex ? (
86
+ <Column
87
+ header={indexHeader}
88
+ headerClassName="ac-sci-index"
89
+ bodyClassName="ac-sci-index"
90
+ align="center"
91
+ alignHeader="center"
92
+ style={{ width: '3.5rem', maxWidth: '3.5rem' }}
93
+ body={(_data: any, options: ColumnBodyOptions) => options.rowIndex + 1}
94
+ />
95
+ ) : null}
96
+ {columns.map((c) => (
97
+ <Column
98
+ key={c.field}
99
+ field={c.field}
100
+ header={<HeaderCell column={c} />}
101
+ align={c.align ?? 'center'}
102
+ alignHeader="center"
103
+ style={c.style}
104
+ bodyClassName="ac-sci-cell"
105
+ body={c.body ? (row: any) => c.body!(row) : undefined}
106
+ />
107
+ ))}
108
+ </DataTable>
109
+ </div>
110
+ </div>
111
+ );
112
+ };
113
+
114
+ export default ScienceTable;