diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ac21a095..34cb094a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { ComponentType } from 'react'; -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: [ diff --git a/src/TecanWorklist/GwlStepView.tsx b/src/TecanWorklist/GwlStepView.tsx new file mode 100644 index 00000000..c1d11ce9 --- /dev/null +++ b/src/TecanWorklist/GwlStepView.tsx @@ -0,0 +1,93 @@ +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 { COMMAND, 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 { + const isUndocumented = step.comment == null; + + return ( + + {isUndocumented ? null : ( + + + {/* The C; prefix stays so a copied selection is valid GWL again. */} + + + {COMMAND.COMMENT} + + + {step.comment} + + + )} + {showCommands || isUndocumented + ? 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.test.tsx b/src/TecanWorklist/TecanWorklist.test.tsx new file mode 100644 index 00000000..a29d88ae --- /dev/null +++ b/src/TecanWorklist/TecanWorklist.test.tsx @@ -0,0 +1,58 @@ +// 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 and again after', 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); + + 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', () => { + 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 new file mode 100644 index 00000000..6f003295 --- /dev/null +++ b/src/TecanWorklist/TecanWorklist.tsx @@ -0,0 +1,62 @@ +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 { GwlStepView } from './GwlStepView'; +import { parseGwl } from './parseGwl'; + +const CodeCard = styled(Card)` + font-family: monospace; +`; + +const CODE_BODY_STYLE: CSSProperties = { + maxHeight: '400px', + overflow: 'auto', + padding: 0, +}; + +export const TECAN_WORKLIST_CODE_ID = 'tecan-worklist-code'; + +export type TecanWorklistProps = { + gwl: string; + toolbar?: ReactNode; +}; + +export function TecanWorklist({ + gwl, + toolbar, +}: TecanWorklistProps): ReactElement { + const [showCommands, setShowCommands] = React.useState(false); + + const steps = React.useMemo(() => parseGwl(gwl), [gwl]); + + return ( + + + {toolbar} + setShowCommands(event.target.checked)} + > + 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 new file mode 100644 index 00000000..3a018405 --- /dev/null +++ b/src/TecanWorklist/TecanWorklistPreview.tsx @@ -0,0 +1,48 @@ +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 TecanTipCountOption = { + tipCount: number; + device: TDevice; +}; + +export type TecanWorklistPreviewProps = { + /** Worklist generated for the selected device. */ + gwl: string; + device: TDevice; + tipCountOptions: Array>; + onDeviceChange: (device: TDevice) => void; +}; + +/** For a worklist that already exists, render TecanWorklist directly. */ +export function TecanWorklistPreview({ + gwl, + device, + tipCountOptions, + onDeviceChange, +}: TecanWorklistPreviewProps): ReactElement { + return ( + + size="small" + options={tipCountOptions.map((option) => ({ + label: `${option.tipCount} Tip`, + value: option.device, + }))} + value={device} + // Wrapped because Select also passes the option, which the consumer has no use for. + onChange={(selected) => onDeviceChange(selected)} + /> + } + /> + ); +} diff --git a/src/TecanWorklist/exampleWorklist.ts b/src/TecanWorklist/exampleWorklist.ts new file mode 100644 index 00000000..e981a92a --- /dev/null +++ b/src/TecanWorklist/exampleWorklist.ts @@ -0,0 +1,35 @@ +/** 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; +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 +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/fieldStyle.ts b/src/TecanWorklist/fieldStyle.ts new file mode 100644 index 00000000..59c99ecf --- /dev/null +++ b/src/TecanWorklist/fieldStyle.ts @@ -0,0 +1,30 @@ +import { CSSProperties } from 'react'; + +import { PALETTE } from '../theme'; + +import { COMMAND, GwlField, GwlFieldRole } from './parseGwl'; + +const PIPETTING_COMMAND_COLOR: Record = { + [COMMAND.ASPIRATE]: PALETTE.red, + [COMMAND.DISPENSE]: PALETTE.gold, + [COMMAND.REAGENT_DISTRIBUTION]: PALETTE.blue, +}; + +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: PIPETTING_COMMAND_COLOR[text] ?? PALETTE.gray7, + }; +} diff --git a/src/TecanWorklist/index.stories.tsx b/src/TecanWorklist/index.stories.tsx new file mode 100644 index 00000000..d3a38e6d --- /dev/null +++ b/src/TecanWorklist/index.stories.tsx @@ -0,0 +1,37 @@ +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, device: 'A' }, + { tipCount: 8, device: 'E' }, +]; + +export default { + title: 'TecanWorklist', +}; + +export function Worklist(): ReactElement { + return ; +} + +export function WorklistOfKnownDevice(): ReactElement { + return ( + Tecan C} /> + ); +} + +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..86cbf106 --- /dev/null +++ b/src/TecanWorklist/index.tsx @@ -0,0 +1,8 @@ +export { TecanWorklist } from './TecanWorklist'; +export type { TecanWorklistProps } from './TecanWorklist'; + +export { TecanWorklistPreview } from './TecanWorklistPreview'; +export type { + TecanWorklistPreviewProps, + TecanTipCountOption, +} from './TecanWorklistPreview'; diff --git a/src/TecanWorklist/parseGwl.test.ts b/src/TecanWorklist/parseGwl.test.ts new file mode 100644 index 00000000..f9a785b7 --- /dev/null +++ b/src/TecanWorklist/parseGwl.test.ts @@ -0,0 +1,117 @@ +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: 'tubeID', 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('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 of an unknown command plain', () => { + const steps = parseGwl('X;21'); + + expect(steps[0]?.commands[0]?.fields).toEqual([ + { 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([]); + }); +}); diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts new file mode 100644 index 00000000..7492f128 --- /dev/null +++ b/src/TecanWorklist/parseGwl.ts @@ -0,0 +1,149 @@ +import { Maybe } from '@mll-lab/js-utils'; + +export type GwlFieldRole = + | 'command' + | 'plain' + | 'position' + | 'tubeID' + | 'volume'; + +export type GwlField = { + role: GwlFieldRole; + text: string; +}; + +type GwlCommandLine = { + lineNumber: number; + fields: Array; +}; + +/** `comment` is null only for commands preceding the first comment. */ +export type GwlStep = { + lineNumber: number; + comment: Maybe; + commands: Array; +}; + +const 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 = { + [COMMAND.ASPIRATE]: 6, + [COMMAND.DISPENSE]: 6, + [COMMAND.REAGENT_DISTRIBUTION]: 11, +}; + +const POSITION_FIELDS: Record> = { + [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, the barcode identifies the tube. */ +const TUBE_ID_FIELD: Record = { + [COMMAND.ASPIRATE]: 5, + [COMMAND.DISPENSE]: 5, +}; + +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, + index, + fieldCount, +}: { + commandLetter: string; + index: number; + fieldCount: number; +}): GwlFieldRole { + if (index === 0) { + return 'command'; + } + + // 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'; + } + + if (index === VOLUME_FIELD[commandLetter]) { + return 'volume'; + } + + if (index === TUBE_ID_FIELD[commandLetter]) { + return 'tubeID'; + } + + if (POSITION_FIELDS[commandLetter]?.includes(index)) { + return 'position'; + } + + return 'plain'; +} + +function parseCommand(line: string, lineNumber: number): GwlCommandLine { + const fieldTexts = line.split(FIELD_SEPARATOR); + const commandLetter = fieldTexts[0] ?? ''; + + return { + lineNumber, + fields: fieldTexts.map((text, index) => ({ + text, + role: fieldRole({ commandLetter, index, fieldCount: fieldTexts.length }), + })), + }; +} + +/** + * A worklist documents itself: each `C;` comment describes what the commands + * following it do, which makes the comment a step and the commands its detail. + */ +export function parseGwl(gwl: string): Array { + const steps: Array = []; + + // 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() === '') { + return; + } + + if (line.startsWith(COMMENT_PREFIX)) { + steps.push({ + lineNumber, + comment: line.slice(COMMENT_PREFIX.length), + commands: [], + }); + + return; + } + + const openStep = steps.at(-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';