From 3428d49a19efa4d82bc35dd3958207a11dfe22ba Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 09:56:34 +0200 Subject: [PATCH 01/10] fix(storybook): load antd styles in preview Storybook renders src/Provider directly, so it never ran the antd.less import that src/index.ts gives consumers. Every story showed antd components unstyled, which made visual review misleading. --- .storybook/preview.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ac21a095..c965ce4f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; import { ComponentType } from 'react'; +// Consumers get these via src/index.ts, Storybook imports src/Provider directly. +// eslint-disable-next-line @mll-lab/no-global-styles +import '../src/antd.less'; import { Provider } from '../src/Provider'; export const decorators = [ From 5a0a24a601daef5cd92f58a8eb1e6b1919ddb365 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 09:56:48 +0200 Subject: [PATCH 02/10] feat(TecanWorklist): add TecanWorklist and TecanWorklistPreview TecanWorklist renders a Tecan Gemini worklist as a code view, grouped into the steps its C; comments describe. The commands carrying out a step stay collapsed behind "Befehle anzeigen", and the gutter keeps the true source line numbers so the collapsed view still points at the file. Highlights the command letter, the pipetted volume and the rack positions. The field indexes come from MLL\Utils\Tecan, so no grammar and no parser dependency is needed - parseGwl is a field map. The line numbers render as CSS generated content: user-select: none alone still lands in the clipboard, and a copied selection has to be valid GWL again. For the same reason the C; prefix stays visible. TecanWorklistPreview adds a selection on top, for the case where the worklist does not exist yet. It offers tip counts rather than Tecans, because the tip count is what shapes the worklist. Which device serves a tip count is the consumer's mapping, passed in and handed back unchanged. A worklist that already exists needs no selection and renders through TecanWorklist directly. Loading and empty states belong to the consumer, which holds the query - the components take the finished worklist string. --- src/TecanWorklist/TecanWorklist.tsx | 179 +++++++++++++++++++++ src/TecanWorklist/TecanWorklistPreview.tsx | 51 ++++++ src/TecanWorklist/exampleWorklist.ts | 26 +++ src/TecanWorklist/index.stories.tsx | 40 +++++ src/TecanWorklist/index.tsx | 16 ++ src/TecanWorklist/parseGwl.test.ts | 91 +++++++++++ src/TecanWorklist/parseGwl.ts | 115 +++++++++++++ src/index.ts | 1 + 8 files changed, 519 insertions(+) create mode 100644 src/TecanWorklist/TecanWorklist.tsx create mode 100644 src/TecanWorklist/TecanWorklistPreview.tsx create mode 100644 src/TecanWorklist/exampleWorklist.ts create mode 100644 src/TecanWorklist/index.stories.tsx create mode 100644 src/TecanWorklist/index.tsx create mode 100644 src/TecanWorklist/parseGwl.test.ts create mode 100644 src/TecanWorklist/parseGwl.ts diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx new file mode 100644 index 00000000..80147df4 --- /dev/null +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -0,0 +1,179 @@ +import React, { CSSProperties, ReactElement, ReactNode } from 'react'; +import styled from 'styled-components'; + +import { Checkbox } from '../Checkbox'; +import { Space } from '../Space'; +import { PALETTE } from '../theme'; + +import { GwlField, GwlFieldRole, GwlStep, parseGwl } from './parseGwl'; + +/** + * Colors the pipetting commands apart, so a glance shows what a step does. + * Commands without a color merely keep the robot going (wash, break, tip type). + */ +const COMMAND_COLOR: Record = { + A: PALETTE.red, // Aspirate + D: PALETTE.gold, // Dispense + R: PALETTE.blue, // ReagentDistribution +}; + +const FIELD_STYLE: Record = { + command: { fontWeight: 'bold' }, + plain: { color: PALETTE.gray6 }, + position: { color: PALETTE.tableHeaderBackgroundColor, fontWeight: 'bold' }, + volume: { color: PALETTE.green, fontWeight: 'bold' }, +}; + +function fieldStyle({ role, text }: GwlField): CSSProperties { + if (role !== 'command') { + return FIELD_STYLE[role]; + } + + return { + ...FIELD_STYLE.command, + color: COMMAND_COLOR[text] ?? PALETTE.gray7, + }; +} + +const CODE_STYLE: CSSProperties = { + backgroundColor: PALETTE.white, + border: `1px solid ${PALETTE.gray3}`, + fontFamily: 'monospace', + maxHeight: '400px', + overflow: 'auto', + width: '100%', +}; + +const LINE_STYLE: CSSProperties = { + display: 'flex', + whiteSpace: 'pre', +}; + +/** + * The line number is generated content, not text: `user-select: none` alone + * still lands in the clipboard, so a copied selection would not be valid GWL. + */ +const Gutter = styled.span` + background-color: ${PALETTE.gray1}; + border-right: 1px solid ${PALETTE.gray3}; + color: ${PALETTE.gray5}; + flex-shrink: 0; + padding-right: 8px; + text-align: right; + width: 4em; + + &::before { + content: attr(data-line-number); + } +`; + +const COMMENT_LINE_STYLE: CSSProperties = { + paddingLeft: '8px', +}; + +const COMMENT_STYLE: CSSProperties = { + color: PALETTE.gray9, + fontWeight: 'bold', +}; + +/** Ties the commands visually to the comment they carry out. */ +const COMMAND_STYLE: CSSProperties = { + borderLeft: `2px solid ${PALETTE.gray3}`, + marginLeft: '8px', + paddingLeft: '10px', +}; + +const STEP_STYLE: CSSProperties = { + paddingBottom: '4px', +}; + +const SEPARATOR_STYLE: CSSProperties = { + color: PALETTE.gray5, +}; + +export type TecanWorklistProps = { + /** Raw Gemini worklist to render. */ + gwl: string; + /** Controls placed before the command toggle, such as a device selection. */ + toolbar?: ReactNode; +}; + +function GwlStepView({ + step, + showCommands, +}: { + step: GwlStep; + showCommands: boolean; +}): ReactElement { + return ( +
+ {step.comment == null ? null : ( +
+ + {/* The C; prefix stays so a copied selection is valid GWL again. */} + + C + ; + {step.comment} + +
+ )} + {showCommands + ? step.commands.map((command) => ( +
+ + + {command.fields.map((field, index) => ( + // The index is the identity of a field: its position in the + // record is what gives it meaning, fields never reorder. + // eslint-disable-next-line react/no-array-index-key + + {index === 0 ? null : ( + ; + )} + {field.text} + + ))} + +
+ )) + : null} +
+ ); +} + +/** + * Renders a Tecan worklist grouped into the steps its comments describe, + * as a code view keeping the source line numbers. + */ +export function TecanWorklist({ + gwl, + toolbar, +}: TecanWorklistProps): ReactElement { + const [showCommands, setShowCommands] = React.useState(false); + + const steps = parseGwl(gwl); + + return ( + + + {toolbar} + setShowCommands(event.target.checked)} + > + Befehle anzeigen + + +
+ {steps.map((step) => ( + + ))} +
+
+ ); +} diff --git a/src/TecanWorklist/TecanWorklistPreview.tsx b/src/TecanWorklist/TecanWorklistPreview.tsx new file mode 100644 index 00000000..5c7e462b --- /dev/null +++ b/src/TecanWorklist/TecanWorklistPreview.tsx @@ -0,0 +1,51 @@ +import React, { ReactElement } from 'react'; + +import { Select } from '../Select'; + +import { TecanWorklist } from './TecanWorklist'; + +/** + * The tip count is what shapes the worklist, so that is what the user picks. + * Which device serves a tip count is the consumer's business. + */ +export type TipCountOption = { + tipCount: number; + /** Device the worklist is requested for, opaque to this component. */ + value: TDevice; +}; + +export type TecanWorklistPreviewProps = { + /** Worklist generated for the selected device. */ + gwl: string; + device: TDevice; + tipCountOptions: Array>; + onDeviceChange: (device: TDevice) => void; +}; + +/** + * Previews the worklist a run would produce, for a tip count the user picks. + * For a worklist that already exists, render TecanWorklist directly. + */ +export function TecanWorklistPreview({ + gwl, + device, + tipCountOptions, + onDeviceChange, +}: TecanWorklistPreviewProps): ReactElement { + return ( + + size="small" + options={tipCountOptions.map(({ tipCount, value }) => ({ + label: `${tipCount} Tip`, + value, + }))} + value={device} + onChange={onDeviceChange} + /> + } + /> + ); +} diff --git a/src/TecanWorklist/exampleWorklist.ts b/src/TecanWorklist/exampleWorklist.ts new file mode 100644 index 00000000..e0c0a3ad --- /dev/null +++ b/src/TecanWorklist/exampleWorklist.ts @@ -0,0 +1,26 @@ +/** Structurally realistic, with placeholder user, timestamp and run number. */ +export const DILUTION_RUN_WORKLIST = `C;Created by mll-lab/php-utils v6.14.0 +C;Date: 2000-01-01 00:00:00 +C;User: mustermann +C;Protocol name: 2000-01-01_00-00-00_DilutionRun1.gwl +C;Transfer von 990 µl von MM-Rack (A1) nach MM-Rack (Q2) +B; +S;21 +A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;1 +D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;1 +W; +A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;2 +D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;2 +W; +C;Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2) +A;MM;;Eppis 32x1.5 ml Cooled;2;;110;Dilution_Run_Mix_High_Dispense;;32 +D;MM;;Eppis 32x1.5 ml Cooled;32;;110;Dilution_Run_Mix_High_Dispense;;32 +W; +B; +S;21 +C;Verteilen von je 250 µl von MM-Rack (Q2) nach FluidX-Rack (A1, B1, C1, D1) +R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0; +R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0; +W; +B; +`; diff --git a/src/TecanWorklist/index.stories.tsx b/src/TecanWorklist/index.stories.tsx new file mode 100644 index 00000000..e7e6bfcd --- /dev/null +++ b/src/TecanWorklist/index.stories.tsx @@ -0,0 +1,40 @@ +import React, { ReactElement } from 'react'; + +import { TecanWorklist } from './TecanWorklist'; +import { TecanWorklistPreview } from './TecanWorklistPreview'; +import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; + +const TIP_COUNT_OPTIONS = [ + { tipCount: 4, value: 'A' }, + { tipCount: 8, value: 'E' }, +]; + +export default { + title: 'TecanWorklist', +}; + +/** An existing worklist, rendered for reading. */ +export function Worklist(): ReactElement { + return ; +} + +/** A worklist whose device is already known, named by the consumer. */ +export function WorklistOfKnownDevice(): ReactElement { + return ( + Tecan C} /> + ); +} + +/** Not yet pipetted, so the tip count is still the user's choice. */ +export function Preview(): ReactElement { + const [device, setDevice] = React.useState('A'); + + return ( + + ); +} diff --git a/src/TecanWorklist/index.tsx b/src/TecanWorklist/index.tsx new file mode 100644 index 00000000..30cd4ac8 --- /dev/null +++ b/src/TecanWorklist/index.tsx @@ -0,0 +1,16 @@ +export { parseGwl } from './parseGwl'; +export type { + GwlCommandLine, + GwlField, + GwlFieldRole, + GwlStep, +} from './parseGwl'; + +export { TecanWorklist } from './TecanWorklist'; +export type { TecanWorklistProps } from './TecanWorklist'; + +export { TecanWorklistPreview } from './TecanWorklistPreview'; +export type { + TecanWorklistPreviewProps, + TipCountOption, +} from './TecanWorklistPreview'; diff --git a/src/TecanWorklist/parseGwl.test.ts b/src/TecanWorklist/parseGwl.test.ts new file mode 100644 index 00000000..7b5d1359 --- /dev/null +++ b/src/TecanWorklist/parseGwl.test.ts @@ -0,0 +1,91 @@ +import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; +import { parseGwl } from './parseGwl'; + +describe('parseGwl', () => { + it('groups commands under the comment preceding them', () => { + const steps = parseGwl('C;Transfer\nW;\nB;\nC;Verteilen\nW;'); + + expect(steps.map((step) => [step.comment, step.commands.length])).toEqual([ + ['Transfer', 2], + ['Verteilen', 1], + ]); + }); + + it('keeps commands preceding the first comment', () => { + const steps = parseGwl('W;\nC;Transfer'); + + expect(steps[0]?.comment).toBeNull(); + expect(steps[0]?.commands).toHaveLength(1); + }); + + it('numbers lines as they appear in the source, blank lines skipped', () => { + const steps = parseGwl('C;Transfer\n\nW;'); + + expect(steps[0]?.lineNumber).toBe(1); + expect(steps[0]?.commands[0]?.lineNumber).toBe(3); + }); + + it('keeps separators inside a comment', () => { + expect(parseGwl('C;Transfer;von;990 µl')[0]?.comment).toBe( + 'Transfer;von;990 µl', + ); + }); + + it('marks volume and position of an aspirate command', () => { + const steps = parseGwl( + 'A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;1', + ); + + expect(steps[0]?.commands[0]?.fields).toEqual([ + { role: 'command', text: 'A' }, + { role: 'plain', text: 'MM' }, + { role: 'plain', text: '' }, + { role: 'plain', text: 'Eppis 32x1.5 ml Cooled' }, + { role: 'position', text: '1' }, + { role: 'plain', text: '' }, + { role: 'volume', text: '198' }, + { role: 'plain', text: 'Dilution_Run_No_Mix' }, + { role: 'plain', text: '' }, + { role: 'plain', text: '1' }, + ]); + }); + + it('marks source and target positions of a reagent distribution', () => { + const steps = parseGwl( + 'R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0;', + ); + const fields = steps[0]?.commands[0]?.fields; + + expect(fields?.[4]).toEqual({ role: 'position', text: '32' }); + expect(fields?.[5]).toEqual({ role: 'position', text: '32' }); + expect(fields?.[9]).toEqual({ role: 'position', text: '1' }); + expect(fields?.[10]).toEqual({ role: 'position', text: '4' }); + expect(fields?.[11]).toEqual({ role: 'volume', text: '125' }); + }); + + it('leaves commands without volume or position unmarked', () => { + const steps = parseGwl('S;21'); + + expect(steps[0]?.commands[0]?.fields).toEqual([ + { role: 'command', text: 'S' }, + { role: 'plain', text: '21' }, + ]); + }); + + it('reads a full worklist as one step per comment', () => { + const steps = parseGwl(DILUTION_RUN_WORKLIST); + + expect(steps.map((step) => step.comment)).toEqual([ + 'Created by mll-lab/php-utils v6.14.0', + 'Date: 2000-01-01 00:00:00', + 'User: mustermann', + 'Protocol name: 2000-01-01_00-00-00_DilutionRun1.gwl', + 'Transfer von 990 µl von MM-Rack (A1) nach MM-Rack (Q2)', + 'Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2)', + 'Verteilen von je 250 µl von MM-Rack (Q2) nach FluidX-Rack (A1, B1, C1, D1)', + ]); + expect(steps.map((step) => step.commands.length)).toEqual([ + 0, 0, 0, 0, 8, 5, 4, + ]); + }); +}); diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts new file mode 100644 index 00000000..10958881 --- /dev/null +++ b/src/TecanWorklist/parseGwl.ts @@ -0,0 +1,115 @@ +import { Maybe } from '@mll-lab/js-utils'; + +/** Roles a command field can play, each highlighted differently. */ +export type GwlFieldRole = 'command' | 'plain' | 'position' | 'volume'; + +export type GwlField = { + role: GwlFieldRole; + text: string; +}; + +export type GwlCommandLine = { + /** 1-based line in the source worklist, so the gutter stays truthful. */ + lineNumber: number; + fields: Array; +}; + +/** + * A comment and the commands carrying it out. + * `comment` is null only for commands preceding the first comment. + */ +export type GwlStep = { + lineNumber: number; + comment: Maybe; + commands: Array; +}; + +const FIELD_SEPARATOR = ';'; + +const COMMENT_PREFIX = `C${FIELD_SEPARATOR}`; + +/** + * Field index of the pipetted volume per command letter. + * Mirrors the serialization in MLL\Utils\Tecan\BasicCommands. + */ +const VOLUME_FIELD: Record = { + A: 6, // Aspirate + D: 6, // Dispense + R: 11, // ReagentDistribution +}; + +/** Field indexes holding a rack position per command letter. */ +const POSITION_FIELDS: Record> = { + A: [4], + D: [4], + R: [4, 5, 9, 10], // source start and end, then target start and end +}; + +function fieldRole(commandLetter: string, index: number): GwlFieldRole { + if (index === 0) { + return 'command'; + } + + if (index === VOLUME_FIELD[commandLetter]) { + return 'volume'; + } + + if (POSITION_FIELDS[commandLetter]?.includes(index)) { + return 'position'; + } + + return 'plain'; +} + +function parseCommand(line: string, lineNumber: number): GwlCommandLine { + const texts = line.split(FIELD_SEPARATOR); + const commandLetter = texts[0] ?? ''; + + return { + lineNumber, + fields: texts.map((text, index) => ({ + text, + role: fieldRole(commandLetter, index), + })), + }; +} + +/** + * Groups a Gemini worklist into the steps it documents. + * + * A worklist documents itself: each `C;` comment describes what the commands + * following it do, so the comment reads as the step and the commands as its detail. + * Blank lines are dropped, the retained line numbers still show where they were. + */ +export function parseGwl(gwl: string): Array { + const steps: Array = []; + + gwl.split('\n').forEach((line, index) => { + const lineNumber = index + 1; + + if (line.trim() === '') { + return; + } + + if (line.startsWith(COMMENT_PREFIX)) { + steps.push({ + lineNumber, + comment: line.slice(COMMENT_PREFIX.length), + commands: [], + }); + + return; + } + + const openStep = steps[steps.length - 1]; + const command = parseCommand(line, lineNumber); + + if (openStep) { + openStep.commands.push(command); + } else { + steps.push({ lineNumber, comment: null, commands: [command] }); + } + }); + + return steps; +} diff --git a/src/index.ts b/src/index.ts index e69690bb..b1c09e40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ export * from './Table'; export * from './Tabs'; export * from './Tag'; export * from './TecanDeckView'; +export * from './TecanWorklist'; export * from './ThermoCyclerProtocol'; export * from './Timeline'; export * from './Tooltip'; From 2c8300fd88edb554c4fbeff0100e3d2559c33963 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 11:18:47 +0200 Subject: [PATCH 03/10] fix(TecanDeckView): render the master mix rack without row J MasterMixRack in php-utils uses CoordinateSystem2x16NoJ, and limes-api derives MasterMixBlockItem coordinates from it, so everything from K down sat one row too low and Q wells fell out of the lookup unnoticed. Also hardens the worklist rendering the review turned up: highlighting now requires the field count a command letter serializes, and a step without a comment always shows its commands so a worklist without any C; line does not render an empty card. --- src/Plate/coordinateSystem2x16NoJ.ts | 29 ++++++ src/Plate/index.tsx | 1 + src/TecanDeckView/labwareMetadata.ts | 6 +- src/TecanWorklist/TecanWorklist.test.tsx | 54 +++++++++++ src/TecanWorklist/TecanWorklist.tsx | 97 ++++++++++--------- .../TecanWorklistPreview.test.tsx | 37 +++++++ src/TecanWorklist/TecanWorklistPreview.tsx | 18 ++-- src/TecanWorklist/exampleWorklist.ts | 9 ++ src/TecanWorklist/index.stories.tsx | 4 +- src/TecanWorklist/index.tsx | 10 +- src/TecanWorklist/parseGwl.test.ts | 72 ++++++++++++-- src/TecanWorklist/parseGwl.ts | 51 ++++++++-- 12 files changed, 308 insertions(+), 80 deletions(-) create mode 100644 src/Plate/coordinateSystem2x16NoJ.ts create mode 100644 src/TecanWorklist/TecanWorklist.test.tsx create mode 100644 src/TecanWorklist/TecanWorklistPreview.test.tsx diff --git a/src/Plate/coordinateSystem2x16NoJ.ts b/src/Plate/coordinateSystem2x16NoJ.ts new file mode 100644 index 00000000..b383c881 --- /dev/null +++ b/src/Plate/coordinateSystem2x16NoJ.ts @@ -0,0 +1,29 @@ +import { CoordinateSystem } from './types'; + +/** + * The Tecan MM block has no J on its rows. + * Mirrors MLL\Utils\Microplate\CoordinateSystem2x16NoJ. + */ +export const COORDINATE_SYSTEM_2X16_NO_J = { + rows: [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + ], + columns: [1, 2], +} as const satisfies CoordinateSystem; + +export type CoordinateSystem2x16NoJ = typeof COORDINATE_SYSTEM_2X16_NO_J; diff --git a/src/Plate/index.tsx b/src/Plate/index.tsx index bef1bfd3..3affbdd1 100644 --- a/src/Plate/index.tsx +++ b/src/Plate/index.tsx @@ -26,6 +26,7 @@ import { export * from './constants'; export * from './coordinateSystem12x8'; export * from './coordinateSystem2x16'; +export * from './coordinateSystem2x16NoJ'; export * from './coordinateSystem6x4'; export * from './types'; export * from './utils'; diff --git a/src/TecanDeckView/labwareMetadata.ts b/src/TecanDeckView/labwareMetadata.ts index e7145799..0f7de556 100644 --- a/src/TecanDeckView/labwareMetadata.ts +++ b/src/TecanDeckView/labwareMetadata.ts @@ -1,7 +1,7 @@ import type { CoordinateSystem } from '../Plate'; import { COORDINATE_SYSTEM_12X8, - COORDINATE_SYSTEM_2X16, + COORDINATE_SYSTEM_2X16_NO_J, COORDINATE_SYSTEM_6X4, } from '../Plate'; @@ -17,7 +17,7 @@ type LabwareMetadata = { gridPosition: GridPosition; /** * Standard mappings per Tecan deck layout: - * - mmPlate: 2x16 (master mix tubes) + * - mmPlate: 2x16 without J (master mix tubes) * - aPlate, bPlate: 6x4 (reagent plates) * - All other positions: 12x8 (standard PCR plates) */ @@ -37,7 +37,7 @@ export const LABWARE_METADATA: Record = { shortLabel: 'MM', color: LABWARE_COLOR_MASTERMIX_PLATE, gridPosition: { row: 0, column: 0 }, - coordinateSystem: COORDINATE_SYSTEM_2X16, + coordinateSystem: COORDINATE_SYSTEM_2X16_NO_J, }, aPlate: { label: 'A Plate (200µl #1)', diff --git a/src/TecanWorklist/TecanWorklist.test.tsx b/src/TecanWorklist/TecanWorklist.test.tsx new file mode 100644 index 00000000..b9fe3a39 --- /dev/null +++ b/src/TecanWorklist/TecanWorklist.test.tsx @@ -0,0 +1,54 @@ +// TODO remove when we can upgrade to @testing-library/user-event:14, whose events actually are awaitable +/* eslint-disable @typescript-eslint/await-thenable */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { Provider } from '../Provider'; + +import { TecanWorklist, TECAN_WORKLIST_CODE_ID } from './TecanWorklist'; +import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; + +const SHOW_COMMANDS_LABEL = 'Befehle anzeigen'; + +describe('TecanWorklist', () => { + it('leads with the comments, holding the commands back until asked for', async () => { + render( + + + , + ); + + expect(screen.getByText('User: mustermann')).toBeInTheDocument(); + expect(screen.queryByText('198')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL)); + + expect(screen.getAllByText('198')).toHaveLength(10); + }); + + it('shows the commands of a worklist documenting nothing, which has no comment to collapse into', () => { + render( + + + , + ); + + expect(screen.getByTestId(TECAN_WORKLIST_CODE_ID).textContent).toBe( + 'B;A;MM;;Eppis;1;;198;;;1W;', + ); + }); + + it('keeps the line numbers out of the text, so a copied selection stays valid GWL', async () => { + render( + + + , + ); + await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL)); + + expect(screen.getByTestId(TECAN_WORKLIST_CODE_ID).textContent).toBe( + 'C;TransferA;MM;;Eppis;1;;198;;;1W;', + ); + }); +}); diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx index 80147df4..9d359d9b 100644 --- a/src/TecanWorklist/TecanWorklist.tsx +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -1,8 +1,10 @@ import React, { CSSProperties, ReactElement, ReactNode } from 'react'; import styled from 'styled-components'; +import { Card } from '../Card'; import { Checkbox } from '../Checkbox'; import { Space } from '../Space'; +import { Typography } from '../Typography'; import { PALETTE } from '../theme'; import { GwlField, GwlFieldRole, GwlStep, parseGwl } from './parseGwl'; @@ -21,6 +23,7 @@ const FIELD_STYLE: Record = { command: { fontWeight: 'bold' }, plain: { color: PALETTE.gray6 }, position: { color: PALETTE.tableHeaderBackgroundColor, fontWeight: 'bold' }, + tubeID: { color: PALETTE.gray9, fontWeight: 'bold' }, volume: { color: PALETTE.green, fontWeight: 'bold' }, }; @@ -35,19 +38,24 @@ function fieldStyle({ role, text }: GwlField): CSSProperties { }; } -const CODE_STYLE: CSSProperties = { - backgroundColor: PALETTE.white, - border: `1px solid ${PALETTE.gray3}`, - fontFamily: 'monospace', +const CodeCard = styled(Card)` + font-family: monospace; +`; + +const CODE_BODY_STYLE: CSSProperties = { maxHeight: '400px', overflow: 'auto', - width: '100%', + padding: 0, }; -const LINE_STYLE: CSSProperties = { - display: 'flex', - whiteSpace: 'pre', -}; +const Step = styled.div` + padding-bottom: 4px; +`; + +const Line = styled.div` + display: flex; + white-space: pre; +`; /** * The line number is generated content, not text: `user-select: none` alone @@ -67,29 +75,22 @@ const Gutter = styled.span` } `; -const COMMENT_LINE_STYLE: CSSProperties = { - paddingLeft: '8px', -}; - -const COMMENT_STYLE: CSSProperties = { - color: PALETTE.gray9, - fontWeight: 'bold', -}; +const Comment = styled.span` + padding-left: 8px; +`; /** Ties the commands visually to the comment they carry out. */ -const COMMAND_STYLE: CSSProperties = { - borderLeft: `2px solid ${PALETTE.gray3}`, - marginLeft: '8px', - paddingLeft: '10px', -}; +const Command = styled.span` + border-left: 2px solid ${PALETTE.gray3}; + margin-left: 8px; + padding-left: 10px; +`; -const STEP_STYLE: CSSProperties = { - paddingBottom: '4px', -}; +function Separator(): ReactElement { + return ;; +} -const SEPARATOR_STYLE: CSSProperties = { - color: PALETTE.gray5, -}; +export const TECAN_WORKLIST_CODE_ID = 'tecan-worklist-code'; export type TecanWorklistProps = { /** Raw Gemini worklist to render. */ @@ -106,39 +107,38 @@ function GwlStepView({ showCommands: boolean; }): ReactElement { return ( -
+ {step.comment == null ? null : ( -
+ {/* The C; prefix stays so a copied selection is valid GWL again. */} - + C - ; - {step.comment} - -
+ + {step.comment} + + )} - {showCommands + {/* An undocumented step has no comment to collapse into, so it always shows. */} + {showCommands || step.comment == null ? step.commands.map((command) => ( -
+ - + {command.fields.map((field, index) => ( // The index is the identity of a field: its position in the // record is what gives it meaning, fields never reorder. // eslint-disable-next-line react/no-array-index-key - {index === 0 ? null : ( - ; - )} + {index === 0 ? null : } {field.text} ))} - -
+ + )) : null} -
+ ); } @@ -162,10 +162,15 @@ export function TecanWorklist({ checked={showCommands} onChange={(event) => setShowCommands(event.target.checked)} > + {/* Show commands */} Befehle anzeigen -
+ {steps.map((step) => ( ))} -
+ ); } diff --git a/src/TecanWorklist/TecanWorklistPreview.test.tsx b/src/TecanWorklist/TecanWorklistPreview.test.tsx new file mode 100644 index 00000000..46692720 --- /dev/null +++ b/src/TecanWorklist/TecanWorklistPreview.test.tsx @@ -0,0 +1,37 @@ +// TODO remove when we can upgrade to @testing-library/user-event:14, which currently does not work with Select +/* eslint-disable @typescript-eslint/await-thenable */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { Provider } from '../Provider'; + +import { TecanWorklistPreview } from './TecanWorklistPreview'; +import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; + +const TIP_COUNT_OPTIONS = [ + { tipCount: 4, device: 'A' }, + { tipCount: 8, device: 'E' }, +]; + +describe('TecanWorklistPreview', () => { + it('offers tip counts and reports the device serving the chosen one', async () => { + const onDeviceChange = jest.fn(); + + render( + + + , + ); + + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(screen.getByText('8 Tip')); + + expect(onDeviceChange).toHaveBeenCalledWith('E'); + }); +}); diff --git a/src/TecanWorklist/TecanWorklistPreview.tsx b/src/TecanWorklist/TecanWorklistPreview.tsx index 5c7e462b..083e05ae 100644 --- a/src/TecanWorklist/TecanWorklistPreview.tsx +++ b/src/TecanWorklist/TecanWorklistPreview.tsx @@ -8,23 +8,26 @@ import { TecanWorklist } from './TecanWorklist'; * The tip count is what shapes the worklist, so that is what the user picks. * Which device serves a tip count is the consumer's business. */ -export type TipCountOption = { +export type TecanTipCountOption = { tipCount: number; /** Device the worklist is requested for, opaque to this component. */ - value: TDevice; + device: TDevice; }; export type TecanWorklistPreviewProps = { /** Worklist generated for the selected device. */ gwl: string; device: TDevice; - tipCountOptions: Array>; + tipCountOptions: Array>; onDeviceChange: (device: TDevice) => void; }; /** * Previews the worklist a run would produce, for a tip count the user picks. * For a worklist that already exists, render TecanWorklist directly. + * + * Exists so the label wording and the placement of the tip count control are + * decided once here, rather than in every app that offers such a preview. */ export function TecanWorklistPreview({ gwl, @@ -38,12 +41,13 @@ export function TecanWorklistPreview({ toolbar={ size="small" - options={tipCountOptions.map(({ tipCount, value }) => ({ - label: `${tipCount} Tip`, - value, + options={tipCountOptions.map((option) => ({ + label: `${option.tipCount} Tip`, + value: option.device, }))} value={device} - onChange={onDeviceChange} + // Wrapped because Select also passes the option, which is none of the consumer's business. + onChange={(selected) => onDeviceChange(selected)} /> } /> diff --git a/src/TecanWorklist/exampleWorklist.ts b/src/TecanWorklist/exampleWorklist.ts index e0c0a3ad..e981a92a 100644 --- a/src/TecanWorklist/exampleWorklist.ts +++ b/src/TecanWorklist/exampleWorklist.ts @@ -12,6 +12,15 @@ W; A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;2 D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;2 W; +A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;4 +D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;4 +W; +A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;8 +D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;8 +W; +A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;16 +D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;16 +W; C;Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2) A;MM;;Eppis 32x1.5 ml Cooled;2;;110;Dilution_Run_Mix_High_Dispense;;32 D;MM;;Eppis 32x1.5 ml Cooled;32;;110;Dilution_Run_Mix_High_Dispense;;32 diff --git a/src/TecanWorklist/index.stories.tsx b/src/TecanWorklist/index.stories.tsx index e7e6bfcd..e71cb0bf 100644 --- a/src/TecanWorklist/index.stories.tsx +++ b/src/TecanWorklist/index.stories.tsx @@ -5,8 +5,8 @@ import { TecanWorklistPreview } from './TecanWorklistPreview'; import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; const TIP_COUNT_OPTIONS = [ - { tipCount: 4, value: 'A' }, - { tipCount: 8, value: 'E' }, + { tipCount: 4, device: 'A' }, + { tipCount: 8, device: 'E' }, ]; export default { diff --git a/src/TecanWorklist/index.tsx b/src/TecanWorklist/index.tsx index 30cd4ac8..86cbf106 100644 --- a/src/TecanWorklist/index.tsx +++ b/src/TecanWorklist/index.tsx @@ -1,16 +1,8 @@ -export { parseGwl } from './parseGwl'; -export type { - GwlCommandLine, - GwlField, - GwlFieldRole, - GwlStep, -} from './parseGwl'; - export { TecanWorklist } from './TecanWorklist'; export type { TecanWorklistProps } from './TecanWorklist'; export { TecanWorklistPreview } from './TecanWorklistPreview'; export type { TecanWorklistPreviewProps, - TipCountOption, + TecanTipCountOption, } from './TecanWorklistPreview'; diff --git a/src/TecanWorklist/parseGwl.test.ts b/src/TecanWorklist/parseGwl.test.ts index 7b5d1359..aca61b75 100644 --- a/src/TecanWorklist/parseGwl.test.ts +++ b/src/TecanWorklist/parseGwl.test.ts @@ -42,7 +42,7 @@ describe('parseGwl', () => { { role: 'plain', text: '' }, { role: 'plain', text: 'Eppis 32x1.5 ml Cooled' }, { role: 'position', text: '1' }, - { role: 'plain', text: '' }, + { role: 'tubeID', text: '' }, { role: 'volume', text: '198' }, { role: 'plain', text: 'Dilution_Run_No_Mix' }, { role: 'plain', text: '' }, @@ -63,15 +63,76 @@ describe('parseGwl', () => { expect(fields?.[11]).toEqual({ role: 'volume', text: '125' }); }); - it('leaves commands without volume or position unmarked', () => { - const steps = parseGwl('S;21'); + it('marks the barcode of an aspirate without a position', () => { + const steps = parseGwl('A;FluidX;;96FluidX;;SA00012345;198;;;1'); + const fields = steps[0]?.commands[0]?.fields; + + expect(fields?.[4]).toEqual({ role: 'position', text: '' }); + expect(fields?.[5]).toEqual({ role: 'tubeID', text: 'SA00012345' }); + }); + + it('leaves fields plain when a command carries more of them than it serializes', () => { + const steps = parseGwl('A;MM;;Eppis 32x1.5 ml; Cooled;1;;198;lc;;1'); + const fields = steps[0]?.commands[0]?.fields ?? []; + + expect(fields.filter((field) => field.role !== 'plain')).toEqual([ + { role: 'command', text: 'A' }, + ]); + }); + + it('leaves fields plain when a command is cut short', () => { + const steps = parseGwl('A;MM;;Eppis;1'); + const fields = steps[0]?.commands[0]?.fields ?? []; + + expect(fields.filter((field) => field.role !== 'plain')).toEqual([ + { role: 'command', text: 'A' }, + ]); + }); + + it('opens a new step at every comment, even between commands', () => { + const steps = parseGwl( + 'C;Transfer\nA;MM;;Eppis;1;;990;;;1\nC;Note\nD;MM;;Eppis;1;;10;;;1', + ); + + expect(steps.map((step) => step.comment)).toEqual(['Transfer', 'Note']); + expect(steps[1]?.commands).toHaveLength(1); + }); + + it('leaves fields of an unknown command plain', () => { + const steps = parseGwl('X;21'); expect(steps[0]?.commands[0]?.fields).toEqual([ - { role: 'command', text: 'S' }, + { role: 'command', text: 'X' }, { role: 'plain', text: '21' }, ]); }); + it('strips the carriage return MLL\\Utils\\Tecan writes', () => { + const steps = parseGwl('C;Transfer\r\nS;21\r\n'); + + expect(steps[0]?.comment).toBe('Transfer'); + expect(steps[0]?.commands[0]?.fields[1]).toEqual({ + role: 'plain', + text: '21', + }); + }); + + // Fails the day the expected field counts drift from what MLL\Utils\Tecan writes, + // which the fallback to plain fields would otherwise hide. + it('highlights a volume in every pipetting command of a worklist', () => { + const unhighlighted = parseGwl(DILUTION_RUN_WORKLIST) + .flatMap((step) => step.commands) + .filter((command) => + ['A', 'D', 'R'].includes(command.fields[0]?.text ?? ''), + ) + .filter( + (command) => !command.fields.some((field) => field.role === 'volume'), + ) + .map((command) => command.lineNumber); + + expect(unhighlighted).toEqual([]); + }); + it('reads a full worklist as one step per comment', () => { const steps = parseGwl(DILUTION_RUN_WORKLIST); @@ -84,8 +145,5 @@ describe('parseGwl', () => { 'Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2)', 'Verteilen von je 250 µl von MM-Rack (Q2) nach FluidX-Rack (A1, B1, C1, D1)', ]); - expect(steps.map((step) => step.commands.length)).toEqual([ - 0, 0, 0, 0, 8, 5, 4, - ]); }); }); diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts index 10958881..60eec3f0 100644 --- a/src/TecanWorklist/parseGwl.ts +++ b/src/TecanWorklist/parseGwl.ts @@ -1,7 +1,12 @@ import { Maybe } from '@mll-lab/js-utils'; /** Roles a command field can play, each highlighted differently. */ -export type GwlFieldRole = 'command' | 'plain' | 'position' | 'volume'; +export type GwlFieldRole = + | 'command' + | 'plain' + | 'position' + | 'tubeID' + | 'volume'; export type GwlField = { role: GwlFieldRole; @@ -15,7 +20,7 @@ export type GwlCommandLine = { }; /** - * A comment and the commands carrying it out. + * A comment and the commands following it. * `comment` is null only for commands preceding the first comment. */ export type GwlStep = { @@ -45,15 +50,48 @@ const POSITION_FIELDS: Record> = { R: [4, 5, 9, 10], // source start and end, then target start and end }; -function fieldRole(commandLetter: string, index: number): GwlFieldRole { +/** + * Field index of the tube barcode per command letter. + * A barcode location carries no position, so this is the only + * identification of the tube being pipetted from or into. + */ +const TUBE_ID_FIELD: Record = { + A: 5, + D: 5, +}; + +/** + * Fields a command letter serializes into, so a line of an unexpected shape + * gets no highlighting rather than highlighting the wrong values. + */ +const HAS_EXPECTED_FIELD_COUNT: Record boolean> = { + A: (count) => count === 10, + D: (count) => count === 10, + R: (count) => count >= 16, // excluded target wells are appended +}; + +function fieldRole( + commandLetter: string, + index: number, + fieldCount: number, +): GwlFieldRole { if (index === 0) { return 'command'; } + const hasExpectedFieldCount = HAS_EXPECTED_FIELD_COUNT[commandLetter]; + if (hasExpectedFieldCount && !hasExpectedFieldCount(fieldCount)) { + return 'plain'; + } + if (index === VOLUME_FIELD[commandLetter]) { return 'volume'; } + if (index === TUBE_ID_FIELD[commandLetter]) { + return 'tubeID'; + } + if (POSITION_FIELDS[commandLetter]?.includes(index)) { return 'position'; } @@ -69,7 +107,7 @@ function parseCommand(line: string, lineNumber: number): GwlCommandLine { lineNumber, fields: texts.map((text, index) => ({ text, - role: fieldRole(commandLetter, index), + role: fieldRole(commandLetter, index, texts.length), })), }; } @@ -84,7 +122,8 @@ function parseCommand(line: string, lineNumber: number): GwlCommandLine { export function parseGwl(gwl: string): Array { const steps: Array = []; - gwl.split('\n').forEach((line, index) => { + // MLL\Utils\Tecan writes CRLF, so a lone \n split would leave \r in the last field. + gwl.split(/\r?\n/).forEach((line, index) => { const lineNumber = index + 1; if (line.trim() === '') { @@ -101,7 +140,7 @@ export function parseGwl(gwl: string): Array { return; } - const openStep = steps[steps.length - 1]; + const openStep = steps.at(-1); const command = parseCommand(line, lineNumber); if (openStep) { From d9acdf59702d7dbe6157a432c72226920ce7d912 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 11:24:15 +0200 Subject: [PATCH 04/10] fix(TecanDeckView): render the master mix rack without row J MasterMixRack in php-utils uses CoordinateSystem2x16NoJ, whose rows run A-I and K-Q, and limes-api derives the MasterMixBlockItem coordinates from it. Rendering those against a row list containing J put everything from K onwards one row too low, and Q wells fell out of the position lookup without any error. COORDINATE_SYSTEM_2X16 stays as it is, so this only adds a coordinate system rather than changing one. --- src/Plate/coordinateSystem2x16NoJ.ts | 29 ++++++++++++++++++++++++++++ src/Plate/index.tsx | 1 + src/TecanDeckView/labwareMetadata.ts | 6 +++--- 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 src/Plate/coordinateSystem2x16NoJ.ts diff --git a/src/Plate/coordinateSystem2x16NoJ.ts b/src/Plate/coordinateSystem2x16NoJ.ts new file mode 100644 index 00000000..b383c881 --- /dev/null +++ b/src/Plate/coordinateSystem2x16NoJ.ts @@ -0,0 +1,29 @@ +import { CoordinateSystem } from './types'; + +/** + * The Tecan MM block has no J on its rows. + * Mirrors MLL\Utils\Microplate\CoordinateSystem2x16NoJ. + */ +export const COORDINATE_SYSTEM_2X16_NO_J = { + rows: [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + ], + columns: [1, 2], +} as const satisfies CoordinateSystem; + +export type CoordinateSystem2x16NoJ = typeof COORDINATE_SYSTEM_2X16_NO_J; diff --git a/src/Plate/index.tsx b/src/Plate/index.tsx index bef1bfd3..3affbdd1 100644 --- a/src/Plate/index.tsx +++ b/src/Plate/index.tsx @@ -26,6 +26,7 @@ import { export * from './constants'; export * from './coordinateSystem12x8'; export * from './coordinateSystem2x16'; +export * from './coordinateSystem2x16NoJ'; export * from './coordinateSystem6x4'; export * from './types'; export * from './utils'; diff --git a/src/TecanDeckView/labwareMetadata.ts b/src/TecanDeckView/labwareMetadata.ts index e7145799..0f7de556 100644 --- a/src/TecanDeckView/labwareMetadata.ts +++ b/src/TecanDeckView/labwareMetadata.ts @@ -1,7 +1,7 @@ import type { CoordinateSystem } from '../Plate'; import { COORDINATE_SYSTEM_12X8, - COORDINATE_SYSTEM_2X16, + COORDINATE_SYSTEM_2X16_NO_J, COORDINATE_SYSTEM_6X4, } from '../Plate'; @@ -17,7 +17,7 @@ type LabwareMetadata = { gridPosition: GridPosition; /** * Standard mappings per Tecan deck layout: - * - mmPlate: 2x16 (master mix tubes) + * - mmPlate: 2x16 without J (master mix tubes) * - aPlate, bPlate: 6x4 (reagent plates) * - All other positions: 12x8 (standard PCR plates) */ @@ -37,7 +37,7 @@ export const LABWARE_METADATA: Record = { shortLabel: 'MM', color: LABWARE_COLOR_MASTERMIX_PLATE, gridPosition: { row: 0, column: 0 }, - coordinateSystem: COORDINATE_SYSTEM_2X16, + coordinateSystem: COORDINATE_SYSTEM_2X16_NO_J, }, aPlate: { label: 'A Plate (200µl #1)', From 33a73f2c23134c516b784189a0c4a19dbe161c80 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 12:36:44 +0200 Subject: [PATCH 05/10] refactor(TecanWorklist): cut comments and tests that carry nothing Comments restating a name or a type were removed, the remaining ones carry a why: the php-utils field indexes, the clipboard constraint on the gutter, the always-visible undocumented step. Dropped three parseGwl tests that were subsets of others: the cut-short command and the too-many-fields command exercise the same guard, a second comment mid-worklist is already covered by the grouping test, and the field-count drift test reads the whole fixture anyway. --- src/TecanWorklist/TecanWorklist.tsx | 14 ++------- src/TecanWorklist/TecanWorklistPreview.tsx | 11 ++----- src/TecanWorklist/parseGwl.test.ts | 36 ++-------------------- src/TecanWorklist/parseGwl.ts | 30 ++++-------------- 4 files changed, 12 insertions(+), 79 deletions(-) diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx index 9d359d9b..2a66d5e5 100644 --- a/src/TecanWorklist/TecanWorklist.tsx +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -9,10 +9,7 @@ import { PALETTE } from '../theme'; import { GwlField, GwlFieldRole, GwlStep, parseGwl } from './parseGwl'; -/** - * Colors the pipetting commands apart, so a glance shows what a step does. - * Commands without a color merely keep the robot going (wash, break, tip type). - */ +/** Only pipetting commands get a color, the rest merely keeps the robot going. */ const COMMAND_COLOR: Record = { A: PALETTE.red, // Aspirate D: PALETTE.gold, // Dispense @@ -79,7 +76,6 @@ const Comment = styled.span` padding-left: 8px; `; -/** Ties the commands visually to the comment they carry out. */ const Command = styled.span` border-left: 2px solid ${PALETTE.gray3}; margin-left: 8px; @@ -126,8 +122,7 @@ function GwlStepView({ {command.fields.map((field, index) => ( - // The index is the identity of a field: its position in the - // record is what gives it meaning, fields never reorder. + // A field is identified by its position, fields never reorder. // eslint-disable-next-line react/no-array-index-key {index === 0 ? null : } @@ -142,10 +137,6 @@ function GwlStepView({ ); } -/** - * Renders a Tecan worklist grouped into the steps its comments describe, - * as a code view keeping the source line numbers. - */ export function TecanWorklist({ gwl, toolbar, @@ -162,7 +153,6 @@ export function TecanWorklist({ checked={showCommands} onChange={(event) => setShowCommands(event.target.checked)} > - {/* Show commands */} Befehle anzeigen diff --git a/src/TecanWorklist/TecanWorklistPreview.tsx b/src/TecanWorklist/TecanWorklistPreview.tsx index 083e05ae..3a018405 100644 --- a/src/TecanWorklist/TecanWorklistPreview.tsx +++ b/src/TecanWorklist/TecanWorklistPreview.tsx @@ -10,7 +10,6 @@ import { TecanWorklist } from './TecanWorklist'; */ export type TecanTipCountOption = { tipCount: number; - /** Device the worklist is requested for, opaque to this component. */ device: TDevice; }; @@ -22,13 +21,7 @@ export type TecanWorklistPreviewProps = { onDeviceChange: (device: TDevice) => void; }; -/** - * Previews the worklist a run would produce, for a tip count the user picks. - * For a worklist that already exists, render TecanWorklist directly. - * - * Exists so the label wording and the placement of the tip count control are - * decided once here, rather than in every app that offers such a preview. - */ +/** For a worklist that already exists, render TecanWorklist directly. */ export function TecanWorklistPreview({ gwl, device, @@ -46,7 +39,7 @@ export function TecanWorklistPreview({ value: option.device, }))} value={device} - // Wrapped because Select also passes the option, which is none of the consumer's business. + // Wrapped because Select also passes the option, which the consumer has no use for. onChange={(selected) => onDeviceChange(selected)} /> } diff --git a/src/TecanWorklist/parseGwl.test.ts b/src/TecanWorklist/parseGwl.test.ts index aca61b75..f9a785b7 100644 --- a/src/TecanWorklist/parseGwl.test.ts +++ b/src/TecanWorklist/parseGwl.test.ts @@ -80,24 +80,6 @@ describe('parseGwl', () => { ]); }); - it('leaves fields plain when a command is cut short', () => { - const steps = parseGwl('A;MM;;Eppis;1'); - const fields = steps[0]?.commands[0]?.fields ?? []; - - expect(fields.filter((field) => field.role !== 'plain')).toEqual([ - { role: 'command', text: 'A' }, - ]); - }); - - it('opens a new step at every comment, even between commands', () => { - const steps = parseGwl( - 'C;Transfer\nA;MM;;Eppis;1;;990;;;1\nC;Note\nD;MM;;Eppis;1;;10;;;1', - ); - - expect(steps.map((step) => step.comment)).toEqual(['Transfer', 'Note']); - expect(steps[1]?.commands).toHaveLength(1); - }); - it('leaves fields of an unknown command plain', () => { const steps = parseGwl('X;21'); @@ -117,8 +99,8 @@ describe('parseGwl', () => { }); }); - // Fails the day the expected field counts drift from what MLL\Utils\Tecan writes, - // which the fallback to plain fields would otherwise hide. + // Fails the day the expected field counts drift from what MLL\Utils\Tecan + // writes, which the fallback to plain fields would otherwise hide. it('highlights a volume in every pipetting command of a worklist', () => { const unhighlighted = parseGwl(DILUTION_RUN_WORKLIST) .flatMap((step) => step.commands) @@ -132,18 +114,4 @@ describe('parseGwl', () => { expect(unhighlighted).toEqual([]); }); - - it('reads a full worklist as one step per comment', () => { - const steps = parseGwl(DILUTION_RUN_WORKLIST); - - expect(steps.map((step) => step.comment)).toEqual([ - 'Created by mll-lab/php-utils v6.14.0', - 'Date: 2000-01-01 00:00:00', - 'User: mustermann', - 'Protocol name: 2000-01-01_00-00-00_DilutionRun1.gwl', - 'Transfer von 990 µl von MM-Rack (A1) nach MM-Rack (Q2)', - 'Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2)', - 'Verteilen von je 250 µl von MM-Rack (Q2) nach FluidX-Rack (A1, B1, C1, D1)', - ]); - }); }); diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts index 60eec3f0..81126ffb 100644 --- a/src/TecanWorklist/parseGwl.ts +++ b/src/TecanWorklist/parseGwl.ts @@ -1,6 +1,5 @@ import { Maybe } from '@mll-lab/js-utils'; -/** Roles a command field can play, each highlighted differently. */ export type GwlFieldRole = | 'command' | 'plain' @@ -14,15 +13,12 @@ export type GwlField = { }; export type GwlCommandLine = { - /** 1-based line in the source worklist, so the gutter stays truthful. */ + /** 1-based line in the source worklist. */ lineNumber: number; fields: Array; }; -/** - * A comment and the commands following it. - * `comment` is null only for commands preceding the first comment. - */ +/** `comment` is null only for commands preceding the first comment. */ export type GwlStep = { lineNumber: number; comment: Maybe; @@ -33,37 +29,26 @@ const FIELD_SEPARATOR = ';'; const COMMENT_PREFIX = `C${FIELD_SEPARATOR}`; -/** - * Field index of the pipetted volume per command letter. - * Mirrors the serialization in MLL\Utils\Tecan\BasicCommands. - */ +/** All field indexes below mirror the serialization in MLL\Utils\Tecan\BasicCommands. */ const VOLUME_FIELD: Record = { A: 6, // Aspirate D: 6, // Dispense R: 11, // ReagentDistribution }; -/** Field indexes holding a rack position per command letter. */ const POSITION_FIELDS: Record> = { A: [4], D: [4], R: [4, 5, 9, 10], // source start and end, then target start and end }; -/** - * Field index of the tube barcode per command letter. - * A barcode location carries no position, so this is the only - * identification of the tube being pipetted from or into. - */ +/** A barcode location carries no position, so the barcode identifies the tube. */ const TUBE_ID_FIELD: Record = { A: 5, D: 5, }; -/** - * Fields a command letter serializes into, so a line of an unexpected shape - * gets no highlighting rather than highlighting the wrong values. - */ +/** A line of an unexpected shape gets no highlighting rather than a wrong one. */ const HAS_EXPECTED_FIELD_COUNT: Record boolean> = { A: (count) => count === 10, D: (count) => count === 10, @@ -113,11 +98,8 @@ function parseCommand(line: string, lineNumber: number): GwlCommandLine { } /** - * Groups a Gemini worklist into the steps it documents. - * * A worklist documents itself: each `C;` comment describes what the commands - * following it do, so the comment reads as the step and the commands as its detail. - * Blank lines are dropped, the retained line numbers still show where they were. + * following it do, which makes the comment a step and the commands its detail. */ export function parseGwl(gwl: string): Array { const steps: Array = []; From 913883618811f0012ae9085be3627223efc86635 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 12:44:55 +0200 Subject: [PATCH 06/10] refactor(TecanWorklist): one file per component Follows the layout of src/Plate: a PascalCase file per component, lowercase files for helpers. --- src/TecanWorklist/GwlStepView.tsx | 88 +++++++++++++++++++++ src/TecanWorklist/Separator.tsx | 7 ++ src/TecanWorklist/TecanWorklist.tsx | 114 +--------------------------- src/TecanWorklist/fieldStyle.ts | 31 ++++++++ 4 files changed, 128 insertions(+), 112 deletions(-) create mode 100644 src/TecanWorklist/GwlStepView.tsx create mode 100644 src/TecanWorklist/Separator.tsx create mode 100644 src/TecanWorklist/fieldStyle.ts diff --git a/src/TecanWorklist/GwlStepView.tsx b/src/TecanWorklist/GwlStepView.tsx new file mode 100644 index 00000000..6b0cc9a2 --- /dev/null +++ b/src/TecanWorklist/GwlStepView.tsx @@ -0,0 +1,88 @@ +import React, { ReactElement } from 'react'; +import styled from 'styled-components'; + +import { Typography } from '../Typography'; +import { PALETTE } from '../theme'; + +import { Separator } from './Separator'; +import { fieldStyle } from './fieldStyle'; +import { GwlStep } from './parseGwl'; + +const Step = styled.div` + padding-bottom: 4px; +`; + +const Line = styled.div` + display: flex; + white-space: pre; +`; + +/** + * The line number is generated content, not text: `user-select: none` alone + * still lands in the clipboard, so a copied selection would not be valid GWL. + */ +const Gutter = styled.span` + background-color: ${PALETTE.gray1}; + border-right: 1px solid ${PALETTE.gray3}; + color: ${PALETTE.gray5}; + flex-shrink: 0; + padding-right: 8px; + text-align: right; + width: 4em; + + &::before { + content: attr(data-line-number); + } +`; + +const Comment = styled.span` + padding-left: 8px; +`; + +const Command = styled.span` + border-left: 2px solid ${PALETTE.gray3}; + margin-left: 8px; + padding-left: 10px; +`; + +export function GwlStepView({ + step, + showCommands, +}: { + step: GwlStep; + showCommands: boolean; +}): ReactElement { + return ( + + {step.comment == null ? null : ( + + + {/* The C; prefix stays so a copied selection is valid GWL again. */} + + C + + {step.comment} + + + )} + {/* An undocumented step has no comment to collapse into, so it always shows. */} + {showCommands || step.comment == null + ? step.commands.map((command) => ( + + + + {command.fields.map((field, index) => ( + // A field is identified by its position, fields never reorder. + // eslint-disable-next-line react/no-array-index-key + + {index === 0 ? null : } + {field.text} + + ))} + + + )) + : null} + + ); +} diff --git a/src/TecanWorklist/Separator.tsx b/src/TecanWorklist/Separator.tsx new file mode 100644 index 00000000..5d09b781 --- /dev/null +++ b/src/TecanWorklist/Separator.tsx @@ -0,0 +1,7 @@ +import React, { ReactElement } from 'react'; + +import { Typography } from '../Typography'; + +export function Separator(): ReactElement { + return ;; +} diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx index 2a66d5e5..39409ea0 100644 --- a/src/TecanWorklist/TecanWorklist.tsx +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -4,36 +4,9 @@ import styled from 'styled-components'; import { Card } from '../Card'; import { Checkbox } from '../Checkbox'; import { Space } from '../Space'; -import { Typography } from '../Typography'; -import { PALETTE } from '../theme'; -import { GwlField, GwlFieldRole, GwlStep, parseGwl } from './parseGwl'; - -/** Only pipetting commands get a color, the rest merely keeps the robot going. */ -const COMMAND_COLOR: Record = { - A: PALETTE.red, // Aspirate - D: PALETTE.gold, // Dispense - R: PALETTE.blue, // ReagentDistribution -}; - -const FIELD_STYLE: Record = { - command: { fontWeight: 'bold' }, - plain: { color: PALETTE.gray6 }, - position: { color: PALETTE.tableHeaderBackgroundColor, fontWeight: 'bold' }, - tubeID: { color: PALETTE.gray9, fontWeight: 'bold' }, - volume: { color: PALETTE.green, fontWeight: 'bold' }, -}; - -function fieldStyle({ role, text }: GwlField): CSSProperties { - if (role !== 'command') { - return FIELD_STYLE[role]; - } - - return { - ...FIELD_STYLE.command, - color: COMMAND_COLOR[text] ?? PALETTE.gray7, - }; -} +import { GwlStepView } from './GwlStepView'; +import { parseGwl } from './parseGwl'; const CodeCard = styled(Card)` font-family: monospace; @@ -45,47 +18,6 @@ const CODE_BODY_STYLE: CSSProperties = { padding: 0, }; -const Step = styled.div` - padding-bottom: 4px; -`; - -const Line = styled.div` - display: flex; - white-space: pre; -`; - -/** - * The line number is generated content, not text: `user-select: none` alone - * still lands in the clipboard, so a copied selection would not be valid GWL. - */ -const Gutter = styled.span` - background-color: ${PALETTE.gray1}; - border-right: 1px solid ${PALETTE.gray3}; - color: ${PALETTE.gray5}; - flex-shrink: 0; - padding-right: 8px; - text-align: right; - width: 4em; - - &::before { - content: attr(data-line-number); - } -`; - -const Comment = styled.span` - padding-left: 8px; -`; - -const Command = styled.span` - border-left: 2px solid ${PALETTE.gray3}; - margin-left: 8px; - padding-left: 10px; -`; - -function Separator(): ReactElement { - return ;; -} - export const TECAN_WORKLIST_CODE_ID = 'tecan-worklist-code'; export type TecanWorklistProps = { @@ -95,48 +27,6 @@ export type TecanWorklistProps = { toolbar?: ReactNode; }; -function GwlStepView({ - step, - showCommands, -}: { - step: GwlStep; - showCommands: boolean; -}): ReactElement { - return ( - - {step.comment == null ? null : ( - - - {/* The C; prefix stays so a copied selection is valid GWL again. */} - - C - - {step.comment} - - - )} - {/* An undocumented step has no comment to collapse into, so it always shows. */} - {showCommands || step.comment == null - ? step.commands.map((command) => ( - - - - {command.fields.map((field, index) => ( - // A field is identified by its position, fields never reorder. - // eslint-disable-next-line react/no-array-index-key - - {index === 0 ? null : } - {field.text} - - ))} - - - )) - : null} - - ); -} - export function TecanWorklist({ gwl, toolbar, diff --git a/src/TecanWorklist/fieldStyle.ts b/src/TecanWorklist/fieldStyle.ts new file mode 100644 index 00000000..4faf742f --- /dev/null +++ b/src/TecanWorklist/fieldStyle.ts @@ -0,0 +1,31 @@ +import { CSSProperties } from 'react'; + +import { PALETTE } from '../theme'; + +import { GwlField, GwlFieldRole } from './parseGwl'; + +/** Only pipetting commands get a color, the rest merely keeps the robot going. */ +const COMMAND_COLOR: Record = { + A: PALETTE.red, // Aspirate + D: PALETTE.gold, // Dispense + R: PALETTE.blue, // ReagentDistribution +}; + +const FIELD_STYLE: Record = { + command: { fontWeight: 'bold' }, + plain: { color: PALETTE.gray6 }, + position: { color: PALETTE.tableHeaderBackgroundColor, fontWeight: 'bold' }, + tubeID: { color: PALETTE.gray9, fontWeight: 'bold' }, + volume: { color: PALETTE.green, fontWeight: 'bold' }, +}; + +export function fieldStyle({ role, text }: GwlField): CSSProperties { + if (role !== 'command') { + return FIELD_STYLE[role]; + } + + return { + ...FIELD_STYLE.command, + color: COMMAND_COLOR[text] ?? PALETTE.gray7, + }; +} From b7e36c1f0b387a89caa54579c1138b1260c04eb0 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 12:49:25 +0200 Subject: [PATCH 07/10] refactor(TecanWorklist): name the command letters instead of commenting them A COMMAND constant translates the letters once, so the field index maps and the color map read themselves. An isUndocumented binding replaces the comment on the always-visible step. --- src/TecanWorklist/GwlStepView.tsx | 15 ++++++---- src/TecanWorklist/TecanWorklist.tsx | 2 -- src/TecanWorklist/fieldStyle.ts | 8 ++--- src/TecanWorklist/parseGwl.ts | 46 ++++++++++++++++++----------- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/TecanWorklist/GwlStepView.tsx b/src/TecanWorklist/GwlStepView.tsx index 6b0cc9a2..c1d11ce9 100644 --- a/src/TecanWorklist/GwlStepView.tsx +++ b/src/TecanWorklist/GwlStepView.tsx @@ -6,7 +6,7 @@ import { PALETTE } from '../theme'; import { Separator } from './Separator'; import { fieldStyle } from './fieldStyle'; -import { GwlStep } from './parseGwl'; +import { COMMAND, GwlStep } from './parseGwl'; const Step = styled.div` padding-bottom: 4px; @@ -52,21 +52,26 @@ export function GwlStepView({ step: GwlStep; showCommands: boolean; }): ReactElement { + const isUndocumented = step.comment == null; + return ( - {step.comment == null ? null : ( + {isUndocumented ? null : ( {/* The C; prefix stays so a copied selection is valid GWL again. */} - C + + {COMMAND.COMMENT} + {step.comment} )} - {/* An undocumented step has no comment to collapse into, so it always shows. */} - {showCommands || step.comment == null + {showCommands || isUndocumented ? step.commands.map((command) => ( diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx index 39409ea0..1d48ec80 100644 --- a/src/TecanWorklist/TecanWorklist.tsx +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -21,9 +21,7 @@ const CODE_BODY_STYLE: CSSProperties = { export const TECAN_WORKLIST_CODE_ID = 'tecan-worklist-code'; export type TecanWorklistProps = { - /** Raw Gemini worklist to render. */ gwl: string; - /** Controls placed before the command toggle, such as a device selection. */ toolbar?: ReactNode; }; diff --git a/src/TecanWorklist/fieldStyle.ts b/src/TecanWorklist/fieldStyle.ts index 4faf742f..b3e568c4 100644 --- a/src/TecanWorklist/fieldStyle.ts +++ b/src/TecanWorklist/fieldStyle.ts @@ -2,13 +2,13 @@ import { CSSProperties } from 'react'; import { PALETTE } from '../theme'; -import { GwlField, GwlFieldRole } from './parseGwl'; +import { COMMAND, GwlField, GwlFieldRole } from './parseGwl'; /** Only pipetting commands get a color, the rest merely keeps the robot going. */ const COMMAND_COLOR: Record = { - A: PALETTE.red, // Aspirate - D: PALETTE.gold, // Dispense - R: PALETTE.blue, // ReagentDistribution + [COMMAND.ASPIRATE]: PALETTE.red, + [COMMAND.DISPENSE]: PALETTE.gold, + [COMMAND.REAGENT_DISTRIBUTION]: PALETTE.blue, }; const FIELD_STYLE: Record = { diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts index 81126ffb..64e03dc7 100644 --- a/src/TecanWorklist/parseGwl.ts +++ b/src/TecanWorklist/parseGwl.ts @@ -27,33 +27,42 @@ export type GwlStep = { const FIELD_SEPARATOR = ';'; -const COMMENT_PREFIX = `C${FIELD_SEPARATOR}`; +export const COMMAND = { + ASPIRATE: 'A', + COMMENT: 'C', + DISPENSE: 'D', + REAGENT_DISTRIBUTION: 'R', +} as const; + +const COMMENT_PREFIX = `${COMMAND.COMMENT}${FIELD_SEPARATOR}`; /** All field indexes below mirror the serialization in MLL\Utils\Tecan\BasicCommands. */ const VOLUME_FIELD: Record = { - A: 6, // Aspirate - D: 6, // Dispense - R: 11, // ReagentDistribution + [COMMAND.ASPIRATE]: 6, + [COMMAND.DISPENSE]: 6, + [COMMAND.REAGENT_DISTRIBUTION]: 11, }; const POSITION_FIELDS: Record> = { - A: [4], - D: [4], - R: [4, 5, 9, 10], // source start and end, then target start and end + [COMMAND.ASPIRATE]: [4], + [COMMAND.DISPENSE]: [4], + // source start and end, then target start and end + [COMMAND.REAGENT_DISTRIBUTION]: [4, 5, 9, 10], }; -/** A barcode location carries no position, so the barcode identifies the tube. */ +/** A barcode location carries no position, the barcode identifies the tube. */ const TUBE_ID_FIELD: Record = { - A: 5, - D: 5, + [COMMAND.ASPIRATE]: 5, + [COMMAND.DISPENSE]: 5, }; -/** A line of an unexpected shape gets no highlighting rather than a wrong one. */ -const HAS_EXPECTED_FIELD_COUNT: Record boolean> = { - A: (count) => count === 10, - D: (count) => count === 10, - R: (count) => count >= 16, // excluded target wells are appended -}; +const SERIALIZES_INTO_FIELD_COUNT: Record boolean> = + { + [COMMAND.ASPIRATE]: (count) => count === 10, + [COMMAND.DISPENSE]: (count) => count === 10, + // excluded target wells are appended + [COMMAND.REAGENT_DISTRIBUTION]: (count) => count >= 16, + }; function fieldRole( commandLetter: string, @@ -64,8 +73,9 @@ function fieldRole( return 'command'; } - const hasExpectedFieldCount = HAS_EXPECTED_FIELD_COUNT[commandLetter]; - if (hasExpectedFieldCount && !hasExpectedFieldCount(fieldCount)) { + // A line of an unexpected shape gets no highlighting rather than a wrong one. + const serializesIntoFieldCount = SERIALIZES_INTO_FIELD_COUNT[commandLetter]; + if (serializesIntoFieldCount && !serializesIntoFieldCount(fieldCount)) { return 'plain'; } From eb744035dd4d01fca22ce8c18712d77a7f2715da Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 12:54:03 +0200 Subject: [PATCH 08/10] refactor(TecanWorklist): name what the remaining comments explained PIPETTING_COMMAND_COLOR says which commands get a color. fieldRole takes an object, so its two numbers cannot be swapped at the call site. The story docs went nowhere without autodocs and only repeated the story name. --- src/TecanWorklist/fieldStyle.ts | 5 ++--- src/TecanWorklist/index.stories.tsx | 3 --- src/TecanWorklist/parseGwl.ts | 22 +++++++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/TecanWorklist/fieldStyle.ts b/src/TecanWorklist/fieldStyle.ts index b3e568c4..59c99ecf 100644 --- a/src/TecanWorklist/fieldStyle.ts +++ b/src/TecanWorklist/fieldStyle.ts @@ -4,8 +4,7 @@ import { PALETTE } from '../theme'; import { COMMAND, GwlField, GwlFieldRole } from './parseGwl'; -/** Only pipetting commands get a color, the rest merely keeps the robot going. */ -const COMMAND_COLOR: Record = { +const PIPETTING_COMMAND_COLOR: Record = { [COMMAND.ASPIRATE]: PALETTE.red, [COMMAND.DISPENSE]: PALETTE.gold, [COMMAND.REAGENT_DISTRIBUTION]: PALETTE.blue, @@ -26,6 +25,6 @@ export function fieldStyle({ role, text }: GwlField): CSSProperties { return { ...FIELD_STYLE.command, - color: COMMAND_COLOR[text] ?? PALETTE.gray7, + color: PIPETTING_COMMAND_COLOR[text] ?? PALETTE.gray7, }; } diff --git a/src/TecanWorklist/index.stories.tsx b/src/TecanWorklist/index.stories.tsx index e71cb0bf..d3a38e6d 100644 --- a/src/TecanWorklist/index.stories.tsx +++ b/src/TecanWorklist/index.stories.tsx @@ -13,19 +13,16 @@ export default { title: 'TecanWorklist', }; -/** An existing worklist, rendered for reading. */ export function Worklist(): ReactElement { return ; } -/** A worklist whose device is already known, named by the consumer. */ export function WorklistOfKnownDevice(): ReactElement { return ( Tecan C} /> ); } -/** Not yet pipetted, so the tip count is still the user's choice. */ export function Preview(): ReactElement { const [device, setDevice] = React.useState('A'); diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts index 64e03dc7..865f6b54 100644 --- a/src/TecanWorklist/parseGwl.ts +++ b/src/TecanWorklist/parseGwl.ts @@ -64,11 +64,15 @@ const SERIALIZES_INTO_FIELD_COUNT: Record boolean> = [COMMAND.REAGENT_DISTRIBUTION]: (count) => count >= 16, }; -function fieldRole( - commandLetter: string, - index: number, - fieldCount: number, -): GwlFieldRole { +function fieldRole({ + commandLetter, + index, + fieldCount, +}: { + commandLetter: string; + index: number; + fieldCount: number; +}): GwlFieldRole { if (index === 0) { return 'command'; } @@ -95,14 +99,14 @@ function fieldRole( } function parseCommand(line: string, lineNumber: number): GwlCommandLine { - const texts = line.split(FIELD_SEPARATOR); - const commandLetter = texts[0] ?? ''; + const fieldTexts = line.split(FIELD_SEPARATOR); + const commandLetter = fieldTexts[0] ?? ''; return { lineNumber, - fields: texts.map((text, index) => ({ + fields: fieldTexts.map((text, index) => ({ text, - role: fieldRole(commandLetter, index, texts.length), + role: fieldRole({ commandLetter, index, fieldCount: fieldTexts.length }), })), }; } From a1a9a58ba90892a2a3ca82ba02602bbbbf8c0fa4 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 12:59:09 +0200 Subject: [PATCH 09/10] perf(TecanWorklist): parse the worklist only when it changes Toggling the commands re-parsed the whole worklist, which is free for a 34 line example and is not for a real dilution run. Collapsing the commands again is now covered too, and GwlCommandLine stops being exported to nobody. --- src/TecanWorklist/TecanWorklist.test.tsx | 6 +++++- src/TecanWorklist/TecanWorklist.tsx | 2 +- src/TecanWorklist/parseGwl.ts | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/TecanWorklist/TecanWorklist.test.tsx b/src/TecanWorklist/TecanWorklist.test.tsx index b9fe3a39..a29d88ae 100644 --- a/src/TecanWorklist/TecanWorklist.test.tsx +++ b/src/TecanWorklist/TecanWorklist.test.tsx @@ -12,7 +12,7 @@ import { DILUTION_RUN_WORKLIST } from './exampleWorklist'; const SHOW_COMMANDS_LABEL = 'Befehle anzeigen'; describe('TecanWorklist', () => { - it('leads with the comments, holding the commands back until asked for', async () => { + it('leads with the comments, holding the commands back until asked for and again after', async () => { render( @@ -25,6 +25,10 @@ describe('TecanWorklist', () => { await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL)); expect(screen.getAllByText('198')).toHaveLength(10); + + await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL)); + + expect(screen.queryByText('198')).not.toBeInTheDocument(); }); it('shows the commands of a worklist documenting nothing, which has no comment to collapse into', () => { diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx index 1d48ec80..6f003295 100644 --- a/src/TecanWorklist/TecanWorklist.tsx +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -31,7 +31,7 @@ export function TecanWorklist({ }: TecanWorklistProps): ReactElement { const [showCommands, setShowCommands] = React.useState(false); - const steps = parseGwl(gwl); + const steps = React.useMemo(() => parseGwl(gwl), [gwl]); return ( diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts index 865f6b54..7492f128 100644 --- a/src/TecanWorklist/parseGwl.ts +++ b/src/TecanWorklist/parseGwl.ts @@ -12,8 +12,7 @@ export type GwlField = { text: string; }; -export type GwlCommandLine = { - /** 1-based line in the source worklist. */ +type GwlCommandLine = { lineNumber: number; fields: Array; }; From f038ab2f3fe0ea989faa80e02ea67287baaa0408 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 24 Aug 2026 16:04:36 +0200 Subject: [PATCH 10/10] refactor(storybook): import the Provider through src/index.ts The barrel already imports antd.less, so Storybook no longer needs its own stylesheet import. Links the php-utils class the MM coordinate system mirrors, rather than naming it. --- .storybook/preview.tsx | 5 +---- src/Plate/coordinateSystem2x16NoJ.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c965ce4f..34cb094a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,7 @@ import * as React from 'react'; import { ComponentType } from 'react'; -// Consumers get these via src/index.ts, Storybook imports src/Provider directly. -// eslint-disable-next-line @mll-lab/no-global-styles -import '../src/antd.less'; -import { Provider } from '../src/Provider'; +import { Provider } from '../src'; export const decorators = [ (Story: ComponentType) => ( diff --git a/src/Plate/coordinateSystem2x16NoJ.ts b/src/Plate/coordinateSystem2x16NoJ.ts index b383c881..ff012f9d 100644 --- a/src/Plate/coordinateSystem2x16NoJ.ts +++ b/src/Plate/coordinateSystem2x16NoJ.ts @@ -2,7 +2,7 @@ import { CoordinateSystem } from './types'; /** * The Tecan MM block has no J on its rows. - * Mirrors MLL\Utils\Microplate\CoordinateSystem2x16NoJ. + * Mirrors https://github.com/mll-lab/php-utils/blob/master/src/Microplate/CoordinateSystem2x16NoJ.php. */ export const COORDINATE_SYSTEM_2X16_NO_J = { rows: [