Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
Expand Down
2 changes: 1 addition & 1 deletion src/Plate/coordinateSystem2x16NoJ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
93 changes: 93 additions & 0 deletions src/TecanWorklist/GwlStepView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Step>
{isUndocumented ? null : (
<Line>
<Gutter data-line-number={step.lineNumber} />
{/* The C; prefix stays so a copied selection is valid GWL again. */}
<Comment>
<span
style={fieldStyle({ role: 'command', text: COMMAND.COMMENT })}
>
{COMMAND.COMMENT}
</span>
<Separator />
<Typography.Text strong>{step.comment}</Typography.Text>
</Comment>
</Line>
)}
{showCommands || isUndocumented
? step.commands.map((command) => (
<Line key={command.lineNumber}>
<Gutter data-line-number={command.lineNumber} />
<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
<React.Fragment key={index}>
{index === 0 ? null : <Separator />}
<span style={fieldStyle(field)}>{field.text}</span>
</React.Fragment>
))}
</Command>
</Line>
))
: null}
</Step>
);
}
7 changes: 7 additions & 0 deletions src/TecanWorklist/Separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React, { ReactElement } from 'react';

import { Typography } from '../Typography';

export function Separator(): ReactElement {
return <Typography.Text type="secondary">;</Typography.Text>;
}
58 changes: 58 additions & 0 deletions src/TecanWorklist/TecanWorklist.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Provider>
<TecanWorklist gwl={DILUTION_RUN_WORKLIST} />
</Provider>,
);

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(
<Provider>
<TecanWorklist gwl={'B;\nA;MM;;Eppis;1;;198;;;1\nW;'} />
</Provider>,
);

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(
<Provider>
<TecanWorklist gwl={'C;Transfer\nA;MM;;Eppis;1;;198;;;1\nW;'} />
</Provider>,
);
await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL));

expect(screen.getByTestId(TECAN_WORKLIST_CODE_ID).textContent).toBe(
'C;TransferA;MM;;Eppis;1;;198;;;1W;',
);
});
});
62 changes: 62 additions & 0 deletions src/TecanWorklist/TecanWorklist.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Space vertical style={{ width: '100%' }}>
<Space>
{toolbar}
<Checkbox
checked={showCommands}
onChange={(event) => setShowCommands(event.target.checked)}
>
Befehle anzeigen
</Checkbox>
</Space>
<CodeCard
size="small"
id={TECAN_WORKLIST_CODE_ID}
bodyStyle={CODE_BODY_STYLE}
>
{steps.map((step) => (
<GwlStepView
key={step.lineNumber}
step={step}
showCommands={showCommands}
/>
))}
</CodeCard>
</Space>
);
}
37 changes: 37 additions & 0 deletions src/TecanWorklist/TecanWorklistPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Provider>
<TecanWorklistPreview
gwl={DILUTION_RUN_WORKLIST}
device="A"
tipCountOptions={TIP_COUNT_OPTIONS}
onDeviceChange={onDeviceChange}
/>
</Provider>,
);

await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByText('8 Tip'));

expect(onDeviceChange).toHaveBeenCalledWith('E');
});
});
48 changes: 48 additions & 0 deletions src/TecanWorklist/TecanWorklistPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<TDevice extends string> = {
tipCount: number;
device: TDevice;
};

export type TecanWorklistPreviewProps<TDevice extends string> = {
/** Worklist generated for the selected device. */
gwl: string;
device: TDevice;
tipCountOptions: Array<TecanTipCountOption<TDevice>>;
onDeviceChange: (device: TDevice) => void;
};

/** For a worklist that already exists, render TecanWorklist directly. */
export function TecanWorklistPreview<TDevice extends string>({
gwl,
device,
tipCountOptions,
onDeviceChange,
}: TecanWorklistPreviewProps<TDevice>): ReactElement {
return (
<TecanWorklist
gwl={gwl}
toolbar={
<Select<TDevice>
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)}
/>
}
/>
);
}
35 changes: 35 additions & 0 deletions src/TecanWorklist/exampleWorklist.ts
Original file line number Diff line number Diff line change
@@ -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;
`;
30 changes: 30 additions & 0 deletions src/TecanWorklist/fieldStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { CSSProperties } from 'react';

import { PALETTE } from '../theme';

import { COMMAND, GwlField, GwlFieldRole } from './parseGwl';

const PIPETTING_COMMAND_COLOR: Record<string, string> = {
[COMMAND.ASPIRATE]: PALETTE.red,
[COMMAND.DISPENSE]: PALETTE.gold,
[COMMAND.REAGENT_DISTRIBUTION]: PALETTE.blue,
};

const FIELD_STYLE: Record<GwlFieldRole, CSSProperties> = {
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,
};
}
Loading
Loading