From c2b4980441790949aa816257fec90a7cbc774724 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 15:05:49 +0200 Subject: [PATCH 01/33] feat(MasterMix): distinguish per reaction ingredients from the master mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingredients like template or standard are added to each reaction individually. They belong to the reaction volume, but must never be multiplied by the number of Ansätze or included in the master mix sum. Callers pass them via the new optional `perReactionIngredients` prop. They render below the Gesamtvolumen row, without a pipetting loss column value, followed by a Reaktionsvolumen row. The exported `reactionVolume` saves callers from summing both lists themselves, e.g. to calculate molarities. Pagination is disabled because a summary row paginated onto the second page is misleading. No caller currently exceeds the previous page size of 10 rows, so nothing changes visually today. 🤖 Generated with Claude Code --- src/MasterMix/MasterMix.test.tsx | 71 ++++++++++++++- src/MasterMix/index.stories.tsx | 22 +++-- src/MasterMix/index.tsx | 87 +++++++++++++++---- .../pipettingLossTableColumn.test.tsx | 40 ++++++++- src/MasterMix/pipettingLossTableColumn.tsx | 14 +-- src/MasterMix/types.ts | 19 +++- 6 files changed, 217 insertions(+), 36 deletions(-) diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index c545258e..de09df63 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { MasterMixIngredient } from './types'; -import { MasterMix } from './index'; +import { MasterMix, reactionVolume } from './index'; describe('MasterMix', () => { const ingredients: Array = [ @@ -53,6 +53,36 @@ describe('MasterMix', () => { expect(screen.getByText(`${totalVolume} µl`)).toBeInTheDocument(); }); + it('excludes per reaction ingredients from the master mix', () => { + render( + , + ); + + expect(screen.getByText('100.2 µl')).toBeInTheDocument(); + expect(screen.getByText('901.8 µl')).toBeInTheDocument(); + expect(screen.getByText('105.2 µl')).toBeInTheDocument(); + expect(screen.getAllByText('–')).toHaveLength(2); + }); + + it('omits the reaction volume without per reaction ingredients', () => { + render( + , + ); + + expect(screen.queryByText('Reaktionsvolumen')).not.toBeInTheDocument(); + }); + it('highlights the clicked ingredient but not the sum', () => { render( { fireEvent.click(screen.getByText('Probe')); expect(numberOfSelectedTableRows()).toBe(1); }); + + it('does not highlight per reaction ingredients', () => { + render( + , + ); + + fireEvent.click(screen.getByText('cDNA')); + + expect( + screen + .getAllByRole('row') + .filter((row) => row.classList.contains('mll-ant-table-row-selected')), + ).toHaveLength(0); + }); +}); + +describe('reactionVolume', () => { + it('sums master mix and per reaction ingredients', () => { + expect( + reactionVolume({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + perReactionIngredients: [{ key: 2, title: 'cDNA', volume: 5 }], + }), + ).toBe(18); + }); + + it('sums master mix ingredients without per reaction ingredients', () => { + expect( + reactionVolume({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + }), + ).toBe(13); + }); }); diff --git a/src/MasterMix/index.stories.tsx b/src/MasterMix/index.stories.tsx index ea412370..61d21341 100644 --- a/src/MasterMix/index.stories.tsx +++ b/src/MasterMix/index.stories.tsx @@ -45,16 +45,22 @@ export default { }, }; +const PER_REACTION_INGREDIENTS: Array = [ + { key: 5, title: 'cDNA', volume: 5 }, +]; + +type StoryProps = MasterMixProps & { + lossType: 'absolute' | 'factor' | 'factorWithMinimum'; + lossValue: number; + minPositions?: number; +}; + export function Default({ lossType = 'factorWithMinimum', lossValue = 0.1, minPositions = 2, ...props -}: MasterMixProps & { - lossType: 'absolute' | 'factor' | 'factorWithMinimum'; - lossValue: number; - minPositions?: number; -}): ReactElement { +}: StoryProps): ReactElement { const pipettingLoss = ((): PipettingLoss => { switch (lossType) { case 'absolute': @@ -72,3 +78,9 @@ export function Default({ return ; } + +export function WithPerReactionIngredients(props: StoryProps): ReactElement { + return ( + + ); +} diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index fe1ca443..4c3b00c2 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -7,7 +7,11 @@ import { Table } from '../Table'; import { Typography } from '../Typography'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; -import { MasterMixProps } from './types'; +import { + MasterMixIngredient, + MasterMixProps, + MasterMixTableRow, +} from './types'; export { MasterMixProps, @@ -20,12 +24,33 @@ export { const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; +// Non-numeric strings, guaranteed to be unique since ingredient keys must be of type number. +const MASTER_MIX_TOTAL_KEY = 'masterMixTotal'; +const REACTION_TOTAL_KEY = 'reactionTotal'; + const MasterMixTable = styled(Table)` .${TOTAL_VOLUME_ROW_CLASS} { background-color: lightgrey; } `; +function sumVolume(ingredients: Array): number { + return ingredients.reduce( + (volumeAccumulator, ingredient) => volumeAccumulator + ingredient.volume, + 0, + ); +} + +/** + * Volume of a single reaction: the master mix plus everything added per reaction. + * Concentrations of the ingredients are relative to this volume. + */ +export function reactionVolume( + mix: Pick, +): number { + return sumVolume([...mix.ingredients, ...(mix.perReactionIngredients ?? [])]); +} + /** * The reactants can be clicked and marked as pipetted. */ @@ -34,17 +59,37 @@ export function MasterMix(props: MasterMixProps) { [], ); - const ingredientsWithSumRow = [ - ...props.ingredients, + const perReactionIngredients = props.perReactionIngredients ?? []; + const masterMixVolume = sumVolume(props.ingredients); + + const reactionTotalRow: Array = + perReactionIngredients.length > 0 + ? [ + { + key: REACTION_TOTAL_KEY, + title:

Reaktionsvolumen

, + volume: masterMixVolume + sumVolume(perReactionIngredients), + rowKind: 'reactionTotal', + }, + ] + : []; + + const rows: Array = [ + ...props.ingredients.map((ingredient) => ({ + ...ingredient, + rowKind: 'masterMixIngredient' as const, + })), { - key: 'Total Volume (non-numeric string, guaranteed to be unique since ingredients keys must be of type number)', + key: MASTER_MIX_TOTAL_KEY, title:

Gesamtvolumen

, - volume: props.ingredients.reduce( - (volumeAccumulator, ingredient) => - volumeAccumulator + ingredient.volume, - 0, - ), + volume: masterMixVolume, + rowKind: 'masterMixTotal', }, + ...perReactionIngredients.map((ingredient) => ({ + ...ingredient, + rowKind: 'perReactionIngredient' as const, + })), + ...reactionTotalRow, ]; return ( @@ -55,8 +100,11 @@ export function MasterMix(props: MasterMixProps) { > { - if (index === props.ingredients.length) { + rowClassName={(record: MasterMixTableRow) => { + if ( + record.rowKind === 'masterMixTotal' || + record.rowKind === 'reactionTotal' + ) { return TOTAL_VOLUME_ROW_CLASS; } @@ -64,13 +112,12 @@ export function MasterMix(props: MasterMixProps) { ? 'mll-ant-table-row-selected' : ''; }} - dataSource={ingredientsWithSumRow} - rowKey={(record) => record.key} - pagination={{ defaultPageSize: 10, hideOnSinglePage: true }} - onRow={(record, index) => ({ + dataSource={rows} + rowKey={(record: MasterMixTableRow) => record.key} + pagination={false} + onRow={(record: MasterMixTableRow) => ({ onClick: () => { - if (index === props.ingredients.length) { - // last row with the sum should not be clickable + if (record.rowKind !== 'masterMixIngredient') { return; } setHighlightedEntries((prevIDs) => @@ -81,11 +128,13 @@ export function MasterMix(props: MasterMixProps) { columns={[ { title: 'Name', - render: (_, record) => record.title, + render: (_: unknown, record: MasterMixTableRow) => record.title, }, { title: '1x', - render: (_, record) => <>{record.volume.toFixed(1)} µl, + render: (_: unknown, record: MasterMixTableRow) => ( + <>{record.volume.toFixed(1)} µl + ), }, pipettingLossTableColumn(props), ]} diff --git a/src/MasterMix/pipettingLossTableColumn.test.tsx b/src/MasterMix/pipettingLossTableColumn.test.tsx index eae67cd5..0ec40587 100644 --- a/src/MasterMix/pipettingLossTableColumn.test.tsx +++ b/src/MasterMix/pipettingLossTableColumn.test.tsx @@ -10,7 +10,15 @@ describe('pipettingLossTableColumn', () => { count: 2, pipettingLoss: { type: 'absolute', count: 1 }, }); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); render(<>{column.title}); expect(screen.getByText('30.0 µl')).toBeInTheDocument(); @@ -24,7 +32,15 @@ describe('pipettingLossTableColumn', () => { count: 2, pipettingLoss: { type: 'factor', factor: 0.1 }, }); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); render(<>{column.title}); expect(screen.getByText('30.0 µl')).toBeInTheDocument(); @@ -47,7 +63,15 @@ describe('pipettingLossTableColumn', () => { }, }); render(<>{column.title}); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); expect(screen.getByText('210.0 µl')).toBeInTheDocument(); expect(screen.getByText('19x Ansätze + 2x (PV)')).toBeInTheDocument(); @@ -67,7 +91,15 @@ describe('pipettingLossTableColumn', () => { }, }); render(<>{column.title}); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); expect(screen.getByText('240.0 µl')).toBeInTheDocument(); expect(screen.getByText('21x Ansätze + 10% (PV)')).toBeInTheDocument(); diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index 3b4ee73d..869fcb64 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Tooltip } from '../Tooltip'; import { - IngredientWithStringOrNumberKey, + MasterMixTableRow, PipettingLoss, PipettingLossFactorWithMinimum, PipettingLossTableColumn, @@ -53,7 +53,7 @@ function pipettingLossTitle( } function totalVolume( - record: IngredientWithStringOrNumberKey, + record: MasterMixTableRow, args: PipettingLossTableColumnArgs, ) { switch (args.pipettingLoss.type) { @@ -90,8 +90,12 @@ export function pipettingLossTableColumn( {pipettingLossTitle(args.pipettingLoss, args.count)} (PV) ), - render: (_: unknown, record: IngredientWithStringOrNumberKey) => ( - <>{totalVolume(record, args)} µl - ), + render: (_: unknown, record: MasterMixTableRow) => + record.rowKind === 'perReactionIngredient' || + record.rowKind === 'reactionTotal' ? ( + <>– + ) : ( + <>{totalVolume(record, args)} µl + ), }; } diff --git a/src/MasterMix/types.ts b/src/MasterMix/types.ts index 10ef9c9f..93103e8f 100644 --- a/src/MasterMix/types.ts +++ b/src/MasterMix/types.ts @@ -12,6 +12,11 @@ export type MasterMixProps = { name: string; count: number; ingredients: Array; + /** + * Ingredients added to each reaction individually, e.g. template or standard. + * They contribute to the reaction volume, but are never part of the shared master mix. + */ + perReactionIngredients?: Array; pipettingLoss: PipettingLoss; }; @@ -34,12 +39,22 @@ export type IngredientWithStringOrNumberKey = Modify< } >; +export type MasterMixTableRowKind = + | 'masterMixIngredient' + | 'masterMixTotal' + | 'perReactionIngredient' + | 'reactionTotal'; + +export type MasterMixTableRow = IngredientWithStringOrNumberKey & { + rowKind: MasterMixTableRowKind; +}; + export type PipettingLossTableColumn = Modify< - ColumnType, + ColumnType, { render: ( value: unknown, - record: IngredientWithStringOrNumberKey, + record: MasterMixTableRow, index: number, ) => React.ReactNode; } From 95a1c02aee3b5fb78b468160157e41bf46dceceb Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 15:29:43 +0200 Subject: [PATCH 02/33] fix(MasterMix): show the master mix nested inside the reaction mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat table did not convey that the master mix is part of the reaction mix, and the title read " MasterMix" even with per reaction ingredients present. - render the master mix in its own card inside the reaction mix card - name the mix after what it actually is: Reaktionsmix or MasterMix - move the per reaction rows into a table without onRow, so Table stops injecting the pointer cursor on rows that are not clickable - split the component into one file per component 🤖 Generated with Claude Code --- src/MasterMix/MasterMix.test.tsx | 36 +++---- src/MasterMix/MasterMixTable.tsx | 71 ++++++++++++ src/MasterMix/ReactionTable.tsx | 57 ++++++++++ src/MasterMix/VolumeTable.tsx | 14 +++ src/MasterMix/index.tsx | 154 +++++++-------------------- src/MasterMix/reactionVolume.test.ts | 20 ++++ src/MasterMix/reactionVolume.ts | 18 ++++ src/MasterMix/volumeColumns.tsx | 28 +++++ 8 files changed, 263 insertions(+), 135 deletions(-) create mode 100644 src/MasterMix/MasterMixTable.tsx create mode 100644 src/MasterMix/ReactionTable.tsx create mode 100644 src/MasterMix/VolumeTable.tsx create mode 100644 src/MasterMix/reactionVolume.test.ts create mode 100644 src/MasterMix/reactionVolume.ts create mode 100644 src/MasterMix/volumeColumns.tsx diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index de09df63..8cfc68bb 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { MasterMixIngredient } from './types'; -import { MasterMix, reactionVolume } from './index'; +import { MasterMix } from './index'; describe('MasterMix', () => { const ingredients: Array = [ @@ -53,6 +53,21 @@ describe('MasterMix', () => { expect(screen.getByText(`${totalVolume} µl`)).toBeInTheDocument(); }); + it('renders as reaction mix containing the master mix', () => { + render( + , + ); + + expect(screen.getByText(`${name} Reaktionsmix`)).toBeInTheDocument(); + expect(screen.getByText('MasterMix')).toBeInTheDocument(); + }); + it('excludes per reaction ingredients from the master mix', () => { render( { ).toHaveLength(0); }); }); - -describe('reactionVolume', () => { - it('sums master mix and per reaction ingredients', () => { - expect( - reactionVolume({ - ingredients: [{ key: 1, title: 'Water', volume: 13 }], - perReactionIngredients: [{ key: 2, title: 'cDNA', volume: 5 }], - }), - ).toBe(18); - }); - - it('sums master mix ingredients without per reaction ingredients', () => { - expect( - reactionVolume({ - ingredients: [{ key: 1, title: 'Water', volume: 13 }], - }), - ).toBe(13); - }); -}); diff --git a/src/MasterMix/MasterMixTable.tsx b/src/MasterMix/MasterMixTable.tsx new file mode 100644 index 00000000..b6e2180e --- /dev/null +++ b/src/MasterMix/MasterMixTable.tsx @@ -0,0 +1,71 @@ +import { toggleElement } from '@mll-lab/js-utils'; +import React, { useState } from 'react'; + +import { TOTAL_VOLUME_ROW_CLASS, VolumeTable } from './VolumeTable'; +import { sumVolume } from './reactionVolume'; +import { + MasterMixIngredient, + MasterMixTableRow, + PipettingLossTableColumnArgs, +} from './types'; +import { volumeColumns } from './volumeColumns'; + +// Non-numeric string, guaranteed to be unique since ingredient keys must be of type number. +const TOTAL_KEY = 'masterMixTotal'; + +export type MasterMixTableProps = PipettingLossTableColumnArgs & { + ingredients: Array; +}; + +/** + * The ingredients can be clicked and marked as pipetted. + */ +export function MasterMixTable({ + ingredients, + ...columnArgs +}: MasterMixTableProps) { + const [highlightedEntries, setHighlightedEntries] = useState>( + [], + ); + + const rows: Array = [ + ...ingredients.map((ingredient) => ({ + ...ingredient, + rowKind: 'masterMixIngredient' as const, + })), + { + key: TOTAL_KEY, + title:

Gesamtvolumen

, + volume: sumVolume(ingredients), + rowKind: 'masterMixTotal', + }, + ]; + + return ( + { + if (record.rowKind === 'masterMixTotal') { + return TOTAL_VOLUME_ROW_CLASS; + } + + return highlightedEntries.includes(record.key.toString()) + ? 'mll-ant-table-row-selected' + : ''; + }} + dataSource={rows} + rowKey={(record: MasterMixTableRow) => record.key} + pagination={false} + onRow={(record: MasterMixTableRow) => ({ + onClick: () => { + if (record.rowKind !== 'masterMixIngredient') { + return; + } + setHighlightedEntries((previouslyHighlighted) => + toggleElement(previouslyHighlighted, record.key.toString()), + ); + }, + })} + columns={volumeColumns(columnArgs)} + /> + ); +} diff --git a/src/MasterMix/ReactionTable.tsx b/src/MasterMix/ReactionTable.tsx new file mode 100644 index 00000000..d01e4d4d --- /dev/null +++ b/src/MasterMix/ReactionTable.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { TOTAL_VOLUME_ROW_CLASS, VolumeTable } from './VolumeTable'; +import { reactionVolume } from './reactionVolume'; +import { + MasterMixIngredient, + MasterMixTableRow, + PipettingLossTableColumnArgs, +} from './types'; +import { volumeColumns } from './volumeColumns'; + +// Non-numeric string, guaranteed to be unique since ingredient keys must be of type number. +const TOTAL_KEY = 'reactionTotal'; + +export type ReactionTableProps = PipettingLossTableColumnArgs & { + masterMixIngredients: Array; + perReactionIngredients: Array; +}; + +/** + * Continues the master mix table with what is added to each reaction individually. + * Those ingredients are pipetted per well, so the pipetting loss does not apply to them. + */ +export function ReactionTable({ + masterMixIngredients, + perReactionIngredients, + ...columnArgs +}: ReactionTableProps) { + const rows: Array = [ + ...perReactionIngredients.map((ingredient) => ({ + ...ingredient, + rowKind: 'perReactionIngredient' as const, + })), + { + key: TOTAL_KEY, + title:

Reaktionsvolumen

, + volume: reactionVolume({ + ingredients: masterMixIngredients, + perReactionIngredients, + }), + rowKind: 'reactionTotal', + }, + ]; + + return ( + + record.rowKind === 'reactionTotal' ? TOTAL_VOLUME_ROW_CLASS : '' + } + dataSource={rows} + rowKey={(record: MasterMixTableRow) => record.key} + pagination={false} + columns={volumeColumns(columnArgs)} + /> + ); +} diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx new file mode 100644 index 00000000..397a8968 --- /dev/null +++ b/src/MasterMix/VolumeTable.tsx @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +import { Table } from '../Table'; + +export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; + +/** Basis of both the master mix and the reaction table, so they look and align the same. */ +export const VolumeTable = styled(Table)` + max-width: 400px; + + .${TOTAL_VOLUME_ROW_CLASS} { + background-color: lightgrey; + } +`; diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index 4c3b00c2..2ac2b663 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -1,18 +1,14 @@ -import { toggleElement } from '@mll-lab/js-utils'; -import React, { useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { Card } from '../Card'; -import { Table } from '../Table'; import { Typography } from '../Typography'; -import { pipettingLossTableColumn } from './pipettingLossTableColumn'; -import { - MasterMixIngredient, - MasterMixProps, - MasterMixTableRow, -} from './types'; +import { MasterMixTable } from './MasterMixTable'; +import { ReactionTable } from './ReactionTable'; +import { MasterMixProps } from './types'; +export { reactionVolume } from './reactionVolume'; export { MasterMixProps, MasterMixIngredient, @@ -22,123 +18,51 @@ export { PipettingLossFactorWithMinimum, } from './types'; -const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; - -// Non-numeric strings, guaranteed to be unique since ingredient keys must be of type number. -const MASTER_MIX_TOTAL_KEY = 'masterMixTotal'; -const REACTION_TOTAL_KEY = 'reactionTotal'; - -const MasterMixTable = styled(Table)` - .${TOTAL_VOLUME_ROW_CLASS} { - background-color: lightgrey; - } +const NestedMasterMixCard = styled(Card)` + max-width: 400px; + margin-bottom: 8px; `; -function sumVolume(ingredients: Array): number { - return ingredients.reduce( - (volumeAccumulator, ingredient) => volumeAccumulator + ingredient.volume, - 0, - ); -} - /** - * Volume of a single reaction: the master mix plus everything added per reaction. - * Concentrations of the ingredients are relative to this volume. + * Shows what to pipette for a given number of reactions. + * Ingredients added per reaction turn the master mix into a part of the reaction mix. */ -export function reactionVolume( - mix: Pick, -): number { - return sumVolume([...mix.ingredients, ...(mix.perReactionIngredients ?? [])]); -} - -/** - * The reactants can be clicked and marked as pipetted. - */ -export function MasterMix(props: MasterMixProps) { - const [highlightedEntries, setHighlightedEntries] = useState>( - [], +export function MasterMix({ + name, + ingredients, + perReactionIngredients, + ...columnArgs +}: MasterMixProps) { + const masterMixTable = ( + ); - const perReactionIngredients = props.perReactionIngredients ?? []; - const masterMixVolume = sumVolume(props.ingredients); - - const reactionTotalRow: Array = - perReactionIngredients.length > 0 - ? [ - { - key: REACTION_TOTAL_KEY, - title:

Reaktionsvolumen

, - volume: masterMixVolume + sumVolume(perReactionIngredients), - rowKind: 'reactionTotal', - }, - ] - : []; - - const rows: Array = [ - ...props.ingredients.map((ingredient) => ({ - ...ingredient, - rowKind: 'masterMixIngredient' as const, - })), - { - key: MASTER_MIX_TOTAL_KEY, - title:

Gesamtvolumen

, - volume: masterMixVolume, - rowKind: 'masterMixTotal', - }, - ...perReactionIngredients.map((ingredient) => ({ - ...ingredient, - rowKind: 'perReactionIngredient' as const, - })), - ...reactionTotalRow, - ]; - return ( {props.name} MasterMix + + {name} {perReactionIngredients?.length ? 'Reaktionsmix' : 'MasterMix'} + } > - { - if ( - record.rowKind === 'masterMixTotal' || - record.rowKind === 'reactionTotal' - ) { - return TOTAL_VOLUME_ROW_CLASS; - } - - return highlightedEntries.includes(record.key.toString()) - ? 'mll-ant-table-row-selected' - : ''; - }} - dataSource={rows} - rowKey={(record: MasterMixTableRow) => record.key} - pagination={false} - onRow={(record: MasterMixTableRow) => ({ - onClick: () => { - if (record.rowKind !== 'masterMixIngredient') { - return; - } - setHighlightedEntries((prevIDs) => - toggleElement(prevIDs, record.key.toString()), - ); - }, - })} - columns={[ - { - title: 'Name', - render: (_: unknown, record: MasterMixTableRow) => record.title, - }, - { - title: '1x', - render: (_: unknown, record: MasterMixTableRow) => ( - <>{record.volume.toFixed(1)} µl - ), - }, - pipettingLossTableColumn(props), - ]} - /> + {perReactionIngredients?.length ? ( + <> + + {masterMixTable} + + + + ) : ( + masterMixTable + )} ); } diff --git a/src/MasterMix/reactionVolume.test.ts b/src/MasterMix/reactionVolume.test.ts new file mode 100644 index 00000000..72a58b2a --- /dev/null +++ b/src/MasterMix/reactionVolume.test.ts @@ -0,0 +1,20 @@ +import { reactionVolume } from './reactionVolume'; + +describe('reactionVolume', () => { + it('sums master mix and per reaction ingredients', () => { + expect( + reactionVolume({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + perReactionIngredients: [{ key: 2, title: 'cDNA', volume: 5 }], + }), + ).toBe(18); + }); + + it('sums master mix ingredients without per reaction ingredients', () => { + expect( + reactionVolume({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + }), + ).toBe(13); + }); +}); diff --git a/src/MasterMix/reactionVolume.ts b/src/MasterMix/reactionVolume.ts new file mode 100644 index 00000000..1da5bc19 --- /dev/null +++ b/src/MasterMix/reactionVolume.ts @@ -0,0 +1,18 @@ +import { MasterMixIngredient, MasterMixProps } from './types'; + +export function sumVolume(ingredients: Array): number { + return ingredients.reduce( + (volumeAccumulator, ingredient) => volumeAccumulator + ingredient.volume, + 0, + ); +} + +/** + * Volume of a single reaction: the master mix plus everything added per reaction. + * Concentrations of the ingredients are relative to this volume. + */ +export function reactionVolume( + mix: Pick, +): number { + return sumVolume([...mix.ingredients, ...(mix.perReactionIngredients ?? [])]); +} diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx new file mode 100644 index 00000000..cf6b501f --- /dev/null +++ b/src/MasterMix/volumeColumns.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { pipettingLossTableColumn } from './pipettingLossTableColumn'; +import { MasterMixTableRow, PipettingLossTableColumnArgs } from './types'; + +// Fixed widths keep the columns of the master mix and the reaction table aligned. +const VOLUME_COLUMN_WIDTH = '80px'; +const PIPETTING_LOSS_COLUMN_WIDTH = '150px'; + +export function volumeColumns(args: PipettingLossTableColumnArgs) { + return [ + { + title: 'Name', + render: (_: unknown, record: MasterMixTableRow) => record.title, + }, + { + title: '1x', + width: VOLUME_COLUMN_WIDTH, + render: (_: unknown, record: MasterMixTableRow) => ( + <>{record.volume.toFixed(1)} µl + ), + }, + { + ...pipettingLossTableColumn(args), + width: PIPETTING_LOSS_COLUMN_WIDTH, + }, + ]; +} From 96fa81ef4457959387212badc36fa1ad3221b172 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:18:28 +0200 Subject: [PATCH 03/33] feat(MasterMix): rework into a single table with a nested master mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One table replaces the two that had to be aligned by hand, which removes the need for fixed column widths and lets every volume be right aligned. The master mix now appears as a labelled block inside the reaction mix, closed off by its total, so what follows reads as added per reaction. Ingredients are indented to reserve room for a check mark that marks them as pipetted without shifting the layout. `mode="recipe"` shows the mix unscaled, for contexts that have no run yet and therefore no meaningful number of reactions. Visible for existing callers: the card hugs the table instead of stretching across the surrounding layout, ingredients carry the indent for the check mark, and volumes for a single reaction are dimmed where a scaled column shows the amount to pipette. 🤖 Generated with Claude Code --- src/MasterMix/MasterMix.test.tsx | 70 ++++++++------- src/MasterMix/MasterMixIngredientName.tsx | 31 +++++++ src/MasterMix/MasterMixTable.tsx | 71 --------------- src/MasterMix/MixTable.tsx | 86 +++++++++++++++++++ src/MasterMix/ReactionTable.tsx | 57 ------------ src/MasterMix/VolumeTable.tsx | 61 ++++++++++++- src/MasterMix/index.stories.tsx | 19 +++- src/MasterMix/index.tsx | 58 ++++++------- src/MasterMix/mixRows.ts | 59 +++++++++++++ .../pipettingLossTableColumn.test.tsx | 16 ++-- src/MasterMix/pipettingLossTableColumn.tsx | 28 +++--- src/MasterMix/reactionVolume.ts | 6 +- src/MasterMix/types.ts | 57 +++++++----- src/MasterMix/volumeColumns.tsx | 52 +++++++---- 14 files changed, 417 insertions(+), 254 deletions(-) create mode 100644 src/MasterMix/MasterMixIngredientName.tsx delete mode 100644 src/MasterMix/MasterMixTable.tsx create mode 100644 src/MasterMix/MixTable.tsx delete mode 100644 src/MasterMix/ReactionTable.tsx create mode 100644 src/MasterMix/mixRows.ts diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index 8cfc68bb..c86c71bf 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -29,7 +29,7 @@ describe('MasterMix', () => { expect(screen.getByText(`${count}x Ansätze + 2x (PV)`)).toBeInTheDocument(); }); - it('renders the ingredients with the correct volume and sum', () => { + it('renders each ingredient volume and their sum', () => { render( { />, ); - ingredients.forEach((ingredient) => { - expect( - screen.getByText(`${ingredient.volume.toFixed(1)} µl`), - ).toBeInTheDocument(); - }); - - const totalVolume = ( - ingredients.reduce((sum, ingredient) => sum + ingredient.volume, 0) * - (count + 2) - ).toFixed(1); - expect(totalVolume === '901.8').toBeTruthy(); - expect(screen.getByText(`${totalVolume} µl`)).toBeInTheDocument(); + expect(screen.getByText('79.5 µl')).toBeInTheDocument(); + expect(screen.getByText('9.2 µl')).toBeInTheDocument(); + expect(screen.getByText('9.0 µl')).toBeInTheDocument(); + expect(screen.getByText('2.5 µl')).toBeInTheDocument(); + expect(screen.getByText('100.2 µl')).toBeInTheDocument(); + expect(screen.getByText('901.8 µl')).toBeInTheDocument(); }); it('renders as reaction mix containing the master mix', () => { @@ -98,7 +92,7 @@ describe('MasterMix', () => { expect(screen.queryByText('Reaktionsvolumen')).not.toBeInTheDocument(); }); - it('highlights the clicked ingredient but not the sum', () => { + it('marks the clicked ingredient as pipetted but not the sum', () => { render( { />, ); - const numberOfSelectedTableRows = () => - screen - .getAllByRole('row') - .filter((row) => row.classList.contains('mll-ant-table-row-selected')) - .length; + const numberOfPipettedIngredients = () => + screen.queryAllByTitle('pipettiert').length; - expect(numberOfSelectedTableRows()).toBe(0); + expect(numberOfPipettedIngredients()).toBe(0); fireEvent.click(screen.getByText('Gesamtvolumen')); - expect(numberOfSelectedTableRows()).toBe(0); + expect(numberOfPipettedIngredients()).toBe(0); fireEvent.click(screen.getByText('Water')); - expect(numberOfSelectedTableRows()).toBe(1); + expect(numberOfPipettedIngredients()).toBe(1); fireEvent.click(screen.getByText('Probe')); - expect(numberOfSelectedTableRows()).toBe(2); + expect(numberOfPipettedIngredients()).toBe(2); fireEvent.click(screen.getByText('Probe')); - expect(numberOfSelectedTableRows()).toBe(1); + expect(numberOfPipettedIngredients()).toBe(1); }); - it('does not highlight per reaction ingredients', () => { + it('does not mark per reaction ingredients as pipetted', () => { render( { fireEvent.click(screen.getByText('cDNA')); - expect( - screen - .getAllByRole('row') - .filter((row) => row.classList.contains('mll-ant-table-row-selected')), - ).toHaveLength(0); + expect(screen.queryAllByTitle('pipettiert')).toHaveLength(0); + }); + + it('shows only the volumes of a single reaction in recipe mode', () => { + render( + , + ); + + expect(screen.getByText('100.2 µl')).toBeInTheDocument(); + expect(screen.getByText('105.2 µl')).toBeInTheDocument(); + expect(screen.queryByText(/Ansätze/)).not.toBeInTheDocument(); + }); + + it('does not mark ingredients as pipetted in recipe mode', () => { + render(); + + fireEvent.click(screen.getByText('Water')); + + expect(screen.queryAllByTitle('pipettiert')).toHaveLength(0); }); }); diff --git a/src/MasterMix/MasterMixIngredientName.tsx b/src/MasterMix/MasterMixIngredientName.tsx new file mode 100644 index 00000000..76990da4 --- /dev/null +++ b/src/MasterMix/MasterMixIngredientName.tsx @@ -0,0 +1,31 @@ +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; + +export const PIPETTED_MARK = '✓'; + +/** + * Indents the ingredient to show it is part of the master mix and reserves the space + * for the mark, so checking one off does not shift the layout. + */ +const MarkSlot = styled.span` + display: inline-block; + width: 20px; + color: ${(props) => props.theme.successColor}; +`; + +export function MasterMixIngredientName({ + pipetted, + children, +}: { + pipetted: boolean; + children: ReactNode; +}) { + return ( + <> + + {pipetted ? PIPETTED_MARK : null} + + {children} + + ); +} diff --git a/src/MasterMix/MasterMixTable.tsx b/src/MasterMix/MasterMixTable.tsx deleted file mode 100644 index b6e2180e..00000000 --- a/src/MasterMix/MasterMixTable.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { toggleElement } from '@mll-lab/js-utils'; -import React, { useState } from 'react'; - -import { TOTAL_VOLUME_ROW_CLASS, VolumeTable } from './VolumeTable'; -import { sumVolume } from './reactionVolume'; -import { - MasterMixIngredient, - MasterMixTableRow, - PipettingLossTableColumnArgs, -} from './types'; -import { volumeColumns } from './volumeColumns'; - -// Non-numeric string, guaranteed to be unique since ingredient keys must be of type number. -const TOTAL_KEY = 'masterMixTotal'; - -export type MasterMixTableProps = PipettingLossTableColumnArgs & { - ingredients: Array; -}; - -/** - * The ingredients can be clicked and marked as pipetted. - */ -export function MasterMixTable({ - ingredients, - ...columnArgs -}: MasterMixTableProps) { - const [highlightedEntries, setHighlightedEntries] = useState>( - [], - ); - - const rows: Array = [ - ...ingredients.map((ingredient) => ({ - ...ingredient, - rowKind: 'masterMixIngredient' as const, - })), - { - key: TOTAL_KEY, - title:

Gesamtvolumen

, - volume: sumVolume(ingredients), - rowKind: 'masterMixTotal', - }, - ]; - - return ( - { - if (record.rowKind === 'masterMixTotal') { - return TOTAL_VOLUME_ROW_CLASS; - } - - return highlightedEntries.includes(record.key.toString()) - ? 'mll-ant-table-row-selected' - : ''; - }} - dataSource={rows} - rowKey={(record: MasterMixTableRow) => record.key} - pagination={false} - onRow={(record: MasterMixTableRow) => ({ - onClick: () => { - if (record.rowKind !== 'masterMixIngredient') { - return; - } - setHighlightedEntries((previouslyHighlighted) => - toggleElement(previouslyHighlighted, record.key.toString()), - ); - }, - })} - columns={volumeColumns(columnArgs)} - /> - ); -} diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx new file mode 100644 index 00000000..b6071ad0 --- /dev/null +++ b/src/MasterMix/MixTable.tsx @@ -0,0 +1,86 @@ +import { toggleElement } from '@mll-lab/js-utils'; +import React, { useState } from 'react'; + +import { + MASTER_MIX_BLOCK_ROW_CLASS, + MASTER_MIX_END_ROW_CLASS, + PIPETTED_ROW_CLASS, + SECTION_ROW_CLASS, + TOTAL_VOLUME_ROW_CLASS, + UNCLICKABLE_ROW_CLASS, + VolumeTable, +} from './VolumeTable'; +import { mixRows } from './mixRows'; +import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types'; +import { volumeColumns } from './volumeColumns'; + +export type MixTableProps = ReactionMix & { + scaling?: PipettingScaling; +}; + +function rowClassName( + record: MasterMixTableRow, + pipettedKeys: Array, + nested: boolean, +): string { + switch (record.rowKind) { + case 'masterMixSection': + return [ + SECTION_ROW_CLASS, + MASTER_MIX_BLOCK_ROW_CLASS, + UNCLICKABLE_ROW_CLASS, + ].join(' '); + case 'masterMixIngredient': + return [ + nested ? MASTER_MIX_BLOCK_ROW_CLASS : '', + pipettedKeys.includes(record.key) ? PIPETTED_ROW_CLASS : '', + ].join(' '); + case 'masterMixTotal': + return [ + TOTAL_VOLUME_ROW_CLASS, + UNCLICKABLE_ROW_CLASS, + nested ? MASTER_MIX_BLOCK_ROW_CLASS : '', + nested ? MASTER_MIX_END_ROW_CLASS : '', + ].join(' '); + case 'perReactionIngredient': + return UNCLICKABLE_ROW_CLASS; + case 'reactionTotal': + return [TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS].join(' '); + } +} + +/** Master mix ingredients can be clicked to mark them as pipetted. */ +export function MixTable({ + ingredients, + perReactionIngredients, + scaling, +}: MixTableProps) { + const [pipettedKeys, setPipettedKeys] = useState>([]); + + const nested = Boolean(perReactionIngredients?.length); + + return ( + record.key} + pagination={false} + rowClassName={(record: MasterMixTableRow) => + rowClassName(record, pipettedKeys, nested) + } + onRow={ + scaling && + ((record: MasterMixTableRow) => ({ + onClick: () => { + if (record.rowKind !== 'masterMixIngredient') { + return; + } + setPipettedKeys((previouslyPipetted) => + toggleElement(previouslyPipetted, record.key), + ); + }, + })) + } + columns={volumeColumns(scaling, pipettedKeys)} + /> + ); +} diff --git a/src/MasterMix/ReactionTable.tsx b/src/MasterMix/ReactionTable.tsx deleted file mode 100644 index d01e4d4d..00000000 --- a/src/MasterMix/ReactionTable.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; - -import { TOTAL_VOLUME_ROW_CLASS, VolumeTable } from './VolumeTable'; -import { reactionVolume } from './reactionVolume'; -import { - MasterMixIngredient, - MasterMixTableRow, - PipettingLossTableColumnArgs, -} from './types'; -import { volumeColumns } from './volumeColumns'; - -// Non-numeric string, guaranteed to be unique since ingredient keys must be of type number. -const TOTAL_KEY = 'reactionTotal'; - -export type ReactionTableProps = PipettingLossTableColumnArgs & { - masterMixIngredients: Array; - perReactionIngredients: Array; -}; - -/** - * Continues the master mix table with what is added to each reaction individually. - * Those ingredients are pipetted per well, so the pipetting loss does not apply to them. - */ -export function ReactionTable({ - masterMixIngredients, - perReactionIngredients, - ...columnArgs -}: ReactionTableProps) { - const rows: Array = [ - ...perReactionIngredients.map((ingredient) => ({ - ...ingredient, - rowKind: 'perReactionIngredient' as const, - })), - { - key: TOTAL_KEY, - title:

Reaktionsvolumen

, - volume: reactionVolume({ - ingredients: masterMixIngredients, - perReactionIngredients, - }), - rowKind: 'reactionTotal', - }, - ]; - - return ( - - record.rowKind === 'reactionTotal' ? TOTAL_VOLUME_ROW_CLASS : '' - } - dataSource={rows} - rowKey={(record: MasterMixTableRow) => record.key} - pagination={false} - columns={volumeColumns(columnArgs)} - /> - ); -} diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 397a8968..2fe73081 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -1,14 +1,67 @@ import styled from 'styled-components'; import { Table } from '../Table'; +import { PALETTE } from '../theme'; export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; +export const MASTER_MIX_BLOCK_ROW_CLASS = 'master-mix-block-row'; +export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; +export const SECTION_ROW_CLASS = 'section-row'; +export const PIPETTED_ROW_CLASS = 'pipetted-row'; +export const UNCLICKABLE_ROW_CLASS = 'unclickable-row'; +export const REFERENCE_VOLUME_CLASS = 'reference-volume'; -/** Basis of both the master mix and the reaction table, so they look and align the same. */ +/** + * The width is set by the surrounding container, since antd wins the specificity tie for + * `max-width` on `.mll-ant-table-wrapper`. + */ export const VolumeTable = styled(Table)` - max-width: 400px; - .${TOTAL_VOLUME_ROW_CLASS} { - background-color: lightgrey; + background-color: ${PALETTE.gray3}; + } + + .${TOTAL_VOLUME_ROW_CLASS}, .${SECTION_ROW_CLASS} { + font-weight: bold; + } + + /* Left edge of the box that holds the master mix, from its label down to its total. */ + .mll-ant-table-tbody > tr.${MASTER_MIX_BLOCK_ROW_CLASS} > td:first-child { + position: relative; + } + + /* Overlaps the gap between the row boxes, which a border per row would leave open. */ + .mll-ant-table-tbody + > tr.${MASTER_MIX_BLOCK_ROW_CLASS} + > td:first-child::before { + content: ''; + position: absolute; + top: -1px; + bottom: -1px; + left: 0; + width: 2px; + background-color: ${(props) => props.theme.dividerColor}; + } + + /* Closes the master mix, so that what follows reads as added per reaction. */ + .mll-ant-table-tbody > tr.${MASTER_MIX_END_ROW_CLASS} > td { + border-bottom: 2px solid ${PALETTE.gray5}; + } + + .mll-ant-table-tbody > tr.${MASTER_MIX_END_ROW_CLASS} + tr > td { + padding-top: 16px; + } + + .${PIPETTED_ROW_CLASS} { + color: ${PALETTE.gray6}; + } + + /* Volumes for a single reaction only serve as a reference where a scaled column exists. */ + .${REFERENCE_VOLUME_CLASS} { + color: ${PALETTE.gray6}; + } + + /* Table sets a pointer cursor on every row as soon as any row is clickable. */ + .${UNCLICKABLE_ROW_CLASS}:hover { + cursor: default; } `; diff --git a/src/MasterMix/index.stories.tsx b/src/MasterMix/index.stories.tsx index 61d21341..00e6645c 100644 --- a/src/MasterMix/index.stories.tsx +++ b/src/MasterMix/index.stories.tsx @@ -1,6 +1,6 @@ import React, { ReactElement } from 'react'; -import { MasterMixIngredient, MasterMixProps, PipettingLoss } from './types'; +import { MasterMixIngredient, PipettingLoss } from './types'; import { MasterMix } from './index'; @@ -49,7 +49,11 @@ const PER_REACTION_INGREDIENTS: Array = [ { key: 5, title: 'cDNA', volume: 5 }, ]; -type StoryProps = MasterMixProps & { +type StoryProps = { + name: string; + count: number; + ingredients: Array; + perReactionIngredients?: Array; lossType: 'absolute' | 'factor' | 'factorWithMinimum'; lossValue: number; minPositions?: number; @@ -84,3 +88,14 @@ export function WithPerReactionIngredients(props: StoryProps): ReactElement { ); } + +export function Recipe({ name, ingredients }: StoryProps): ReactElement { + return ( + + ); +} diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index 2ac2b663..29ad5a67 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -4,65 +4,63 @@ import styled from 'styled-components'; import { Card } from '../Card'; import { Typography } from '../Typography'; -import { MasterMixTable } from './MasterMixTable'; -import { ReactionTable } from './ReactionTable'; +import { MixTable } from './MixTable'; +import { MASTER_MIX_LABEL } from './mixRows'; import { MasterMixProps } from './types'; export { reactionVolume } from './reactionVolume'; export { MasterMixProps, MasterMixIngredient, + ReactionMix, + PipettingScaling, + RecipeMode, PipettingLoss, PipettingLossAbsolute, PipettingLossByFactor, PipettingLossFactorWithMinimum, } from './types'; -const NestedMasterMixCard = styled(Card)` +/** Hugs the table instead of stretching across whatever the surrounding layout offers. */ +const MixCard = styled(Card)` + width: fit-content; +`; + +const MixContent = styled.div` max-width: 400px; - margin-bottom: 8px; `; /** - * Shows what to pipette for a given number of reactions. + * Shows what to pipette for a given number of reactions, or as a plain recipe in `mode="recipe"`. * Ingredients added per reaction turn the master mix into a part of the reaction mix. */ export function MasterMix({ name, + mode, + count, + pipettingLoss, ingredients, perReactionIngredients, - ...columnArgs }: MasterMixProps) { - const masterMixTable = ( - - ); + const scaling = + mode === 'recipe' ? undefined : { count, pipettingLoss, mode }; return ( - - {name} {perReactionIngredients?.length ? 'Reaktionsmix' : 'MasterMix'} + {name}{' '} + {perReactionIngredients?.length ? 'Reaktionsmix' : MASTER_MIX_LABEL} } > - {perReactionIngredients?.length ? ( - <> - - {masterMixTable} - - - - ) : ( - masterMixTable - )} - + + + + ); } diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts new file mode 100644 index 00000000..d0000aa2 --- /dev/null +++ b/src/MasterMix/mixRows.ts @@ -0,0 +1,59 @@ +import { reactionVolume, sumVolume } from './reactionVolume'; +import { MasterMixTableRow, ReactionMix } from './types'; + +// Non-numeric strings, guaranteed to be unique since ingredient keys must be of type number. +const MASTER_MIX_SECTION_KEY = 'masterMixSection'; +const MASTER_MIX_TOTAL_KEY = 'masterMixTotal'; +const REACTION_TOTAL_KEY = 'reactionTotal'; + +export const MASTER_MIX_LABEL = 'MasterMix'; + +/** + * Lists what to pipette, from the inside out: the ingredients of the master mix, + * their total as the amount that goes into each reaction, and what is added per reaction. + */ +export function mixRows({ + ingredients, + perReactionIngredients, +}: ReactionMix): Array { + const masterMixTotal: MasterMixTableRow = { + key: MASTER_MIX_TOTAL_KEY, + title: 'Gesamtvolumen', + volume: sumVolume(ingredients), + rowKind: 'masterMixTotal', + }; + const masterMixIngredients: Array = ingredients.map( + (ingredient) => ({ + ...ingredient, + key: ingredient.key.toString(), + rowKind: 'masterMixIngredient', + }), + ); + + if (!perReactionIngredients?.length) { + return [...masterMixIngredients, masterMixTotal]; + } + + return [ + { + key: MASTER_MIX_SECTION_KEY, + title: MASTER_MIX_LABEL, + rowKind: 'masterMixSection', + }, + ...masterMixIngredients, + masterMixTotal, + ...perReactionIngredients.map( + (ingredient): MasterMixTableRow => ({ + ...ingredient, + key: ingredient.key.toString(), + rowKind: 'perReactionIngredient', + }), + ), + { + key: REACTION_TOTAL_KEY, + title: 'Reaktionsvolumen', + volume: reactionVolume({ ingredients, perReactionIngredients }), + rowKind: 'reactionTotal', + }, + ]; +} diff --git a/src/MasterMix/pipettingLossTableColumn.test.tsx b/src/MasterMix/pipettingLossTableColumn.test.tsx index 0ec40587..fcefa4d0 100644 --- a/src/MasterMix/pipettingLossTableColumn.test.tsx +++ b/src/MasterMix/pipettingLossTableColumn.test.tsx @@ -5,7 +5,7 @@ import { pipettingLossTableColumn } from './pipettingLossTableColumn'; describe('pipettingLossTableColumn', () => { describe('with "absolute" pipetting loss type', () => { - it('should render the total volume and title correctly', () => { + it('adds the absolute loss to the scaled volume', () => { const column = pipettingLossTableColumn({ count: 2, pipettingLoss: { type: 'absolute', count: 1 }, @@ -14,7 +14,7 @@ describe('pipettingLossTableColumn', () => { <> {column.render( null, - { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, 1, )} , @@ -27,7 +27,7 @@ describe('pipettingLossTableColumn', () => { }); describe('with "factor" pipetting loss type', () => { - it('should render the total volume and title correctly', () => { + it('adds the loss factor to the scaled volume', () => { const column = pipettingLossTableColumn({ count: 2, pipettingLoss: { type: 'factor', factor: 0.1 }, @@ -36,7 +36,7 @@ describe('pipettingLossTableColumn', () => { <> {column.render( null, - { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, 1, )} , @@ -49,7 +49,7 @@ describe('pipettingLossTableColumn', () => { }); describe('with "factorWithMinimum" pipetting loss type', () => { - it('should use minimum positions when factor loss is slightly below minimum positions', () => { + it('uses the minimum positions when the factor loss stays below them', () => { // 19 ansätze × 10µl = 190µl // factor loss: ceil(19 × 10%) = ceil(1.9) = 2 positions × 10µl = 20µl // min positions loss: 2 × 10µl = 20µl @@ -67,7 +67,7 @@ describe('pipettingLossTableColumn', () => { <> {column.render( null, - { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, 1, )} , @@ -77,7 +77,7 @@ describe('pipettingLossTableColumn', () => { expect(screen.getByText('19x Ansätze + 2x (PV)')).toBeInTheDocument(); }); - it('should use factor loss when it is slightly above minimum positions', () => { + it('uses the factor loss when it exceeds the minimum positions', () => { // 21 ansätze × 10µl = 210µl // factor loss: ceil(21 × 10%) = ceil(2.1) = 3 positions × 10µl = 30µl // min positions loss: 2 × 10µl = 20µl @@ -95,7 +95,7 @@ describe('pipettingLossTableColumn', () => { <> {column.render( null, - { volume: 10, title: '', key: 1, rowKind: 'masterMixIngredient' }, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, 1, )} , diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index 869fcb64..5e27de6e 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -7,7 +7,7 @@ import { PipettingLoss, PipettingLossFactorWithMinimum, PipettingLossTableColumn, - PipettingLossTableColumnArgs, + PipettingScaling, } from './types'; type PipettingLosses = { @@ -53,8 +53,8 @@ function pipettingLossTitle( } function totalVolume( - record: MasterMixTableRow, - args: PipettingLossTableColumnArgs, + record: Exclude, + args: PipettingScaling, ) { switch (args.pipettingLoss.type) { case 'absolute': @@ -81,21 +81,27 @@ function totalVolume( } export function pipettingLossTableColumn( - args: PipettingLossTableColumnArgs, + args: PipettingScaling, ): PipettingLossTableColumn { return { + align: 'right', title: ( {args.count}x Ansätze +{' '} {pipettingLossTitle(args.pipettingLoss, args.count)} (PV) ), - render: (_: unknown, record: MasterMixTableRow) => - record.rowKind === 'perReactionIngredient' || - record.rowKind === 'reactionTotal' ? ( - <>– - ) : ( - <>{totalVolume(record, args)} µl - ), + render: (_: unknown, record: MasterMixTableRow) => { + switch (record.rowKind) { + case 'masterMixSection': + return null; + /** Pipetted into each reaction individually, so the loss does not apply. */ + case 'perReactionIngredient': + case 'reactionTotal': + return <>–; + default: + return <>{totalVolume(record, args)} µl; + } + }, }; } diff --git a/src/MasterMix/reactionVolume.ts b/src/MasterMix/reactionVolume.ts index 1da5bc19..aa7780a1 100644 --- a/src/MasterMix/reactionVolume.ts +++ b/src/MasterMix/reactionVolume.ts @@ -1,4 +1,4 @@ -import { MasterMixIngredient, MasterMixProps } from './types'; +import { MasterMixIngredient, ReactionMix } from './types'; export function sumVolume(ingredients: Array): number { return ingredients.reduce( @@ -11,8 +11,6 @@ export function sumVolume(ingredients: Array): number { * Volume of a single reaction: the master mix plus everything added per reaction. * Concentrations of the ingredients are relative to this volume. */ -export function reactionVolume( - mix: Pick, -): number { +export function reactionVolume(mix: ReactionMix): number { return sumVolume([...mix.ingredients, ...(mix.perReactionIngredients ?? [])]); } diff --git a/src/MasterMix/types.ts b/src/MasterMix/types.ts index 93103e8f..a6682b8b 100644 --- a/src/MasterMix/types.ts +++ b/src/MasterMix/types.ts @@ -8,18 +8,34 @@ export type MasterMixIngredient = { volume: number; }; -export type MasterMixProps = { - name: string; - count: number; +export type ReactionMix = { ingredients: Array; /** * Ingredients added to each reaction individually, e.g. template or standard. * They contribute to the reaction volume, but are never part of the shared master mix. */ perReactionIngredients?: Array; +}; + +/** Scales the mix to a concrete run, e.g. a work list for a plate with 20 wells. */ +export type PipettingScaling = { + mode?: never; + count: number; pipettingLoss: PipettingLoss; }; +/** Shows the mix without scaling it, for contexts where no run exists yet. */ +export type RecipeMode = { + mode: 'recipe'; + count?: never; + pipettingLoss?: never; +}; + +export type MasterMixProps = { + name: string; +} & ReactionMix & + (PipettingScaling | RecipeMode); + export type PipettingLossAbsolute = { type: 'absolute'; count: number }; export type PipettingLossByFactor = { type: 'factor'; factor: number }; export type PipettingLossFactorWithMinimum = { @@ -32,23 +48,24 @@ export type PipettingLoss = | PipettingLossAbsolute | PipettingLossFactorWithMinimum; -export type IngredientWithStringOrNumberKey = Modify< - MasterMixIngredient, - { - key: string | number; - } ->; - -export type MasterMixTableRowKind = - | 'masterMixIngredient' - | 'masterMixTotal' - | 'perReactionIngredient' - | 'reactionTotal'; - -export type MasterMixTableRow = IngredientWithStringOrNumberKey & { - rowKind: MasterMixTableRowKind; +/** Keyed by string, since the rows the table adds around the ingredients have no numeric key. */ +type MixTableRow = { + key: string; + title: MasterMixIngredient['title']; }; +export type MasterMixTableRow = + | (MixTableRow & { + volume: number; + rowKind: + | 'masterMixIngredient' + | 'masterMixTotal' + | 'perReactionIngredient' + | 'reactionTotal'; + }) + /** Labels the master mix within the reaction mix, so it carries no volume of its own. */ + | (MixTableRow & { rowKind: 'masterMixSection' }); + export type PipettingLossTableColumn = Modify< ColumnType, { @@ -59,7 +76,3 @@ export type PipettingLossTableColumn = Modify< ) => React.ReactNode; } >; -export type PipettingLossTableColumnArgs = { - count: number; - pipettingLoss: PipettingLoss; -}; diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index cf6b501f..b453e4e1 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -1,28 +1,50 @@ import React from 'react'; +import { MasterMixIngredientName } from './MasterMixIngredientName'; +import { REFERENCE_VOLUME_CLASS } from './VolumeTable'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; -import { MasterMixTableRow, PipettingLossTableColumnArgs } from './types'; +import { + MasterMixTableRow, + PipettingLossTableColumn, + PipettingScaling, +} from './types'; -// Fixed widths keep the columns of the master mix and the reaction table aligned. -const VOLUME_COLUMN_WIDTH = '80px'; -const PIPETTING_LOSS_COLUMN_WIDTH = '150px'; +/** Only the master mix is scaled, so all other volumes are shown for a single reaction only. */ +function isScaled(record: MasterMixTableRow): boolean { + return ( + record.rowKind === 'masterMixIngredient' || + record.rowKind === 'masterMixTotal' + ); +} -export function volumeColumns(args: PipettingLossTableColumnArgs) { +export function volumeColumns( + scaling: PipettingScaling | undefined, + pipettedKeys: Array, +): Array { return [ { title: 'Name', - render: (_: unknown, record: MasterMixTableRow) => record.title, - }, - { - title: '1x', - width: VOLUME_COLUMN_WIDTH, - render: (_: unknown, record: MasterMixTableRow) => ( - <>{record.volume.toFixed(1)} µl - ), + render: (_: unknown, record: MasterMixTableRow) => + record.rowKind === 'masterMixIngredient' ? ( + + {record.title} + + ) : ( + record.title + ), }, { - ...pipettingLossTableColumn(args), - width: PIPETTING_LOSS_COLUMN_WIDTH, + title: scaling ? '1x' : 'Volumen', + align: 'right', + onCell: (record: MasterMixTableRow) => ({ + className: + scaling && isScaled(record) ? REFERENCE_VOLUME_CLASS : undefined, + }), + render: (_: unknown, record: MasterMixTableRow) => + record.rowKind === 'masterMixSection' ? null : ( + <>{record.volume.toFixed(1)} µl + ), }, + ...(scaling ? [pipettingLossTableColumn(scaling)] : []), ]; } From 25d4d803f042e39e4200bedfce9f43dff3e39668 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:42:03 +0200 Subject: [PATCH 04/33] fix(MasterMix): namespace row keys by the kind of row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ingredient lists are keyed by the consumer and existing callers number each list from 1, so passing the same key in both collided into one React row key. React then warns about duplicate children and remounts the affected row on every re-render, e.g. on each pipetted toggle. 🤖 Generated with Claude Code --- src/MasterMix/MasterMix.test.tsx | 25 +++++++++++++++++++++++++ src/MasterMix/mixRows.ts | 19 +++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index c86c71bf..94aef11c 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -79,6 +79,31 @@ describe('MasterMix', () => { expect(screen.getAllByText('–')).toHaveLength(2); }); + it('keeps the rows apart when both ingredient lists use the same key', () => { + render( + , + ); + + /* + * The row key is only observable through the attribute antd renders it into, + * which no user-facing query can reach. + */ + /* eslint-disable testing-library/no-node-access */ + expect( + document.querySelector('[data-row-key="masterMixIngredient-1"]'), + ).toBeInTheDocument(); + expect( + document.querySelector('[data-row-key="perReactionIngredient-1"]'), + ).toBeInTheDocument(); + /* eslint-enable testing-library/no-node-access */ + }); + it('omits the reaction volume without per reaction ingredients', () => { render( = ingredients.map( (ingredient) => ({ ...ingredient, - key: ingredient.key.toString(), + key: ingredientRowKey('masterMixIngredient', ingredient), rowKind: 'masterMixIngredient', }), ); @@ -45,7 +56,7 @@ export function mixRows({ ...perReactionIngredients.map( (ingredient): MasterMixTableRow => ({ ...ingredient, - key: ingredient.key.toString(), + key: ingredientRowKey('perReactionIngredient', ingredient), rowKind: 'perReactionIngredient', }), ), From f24af8e8d36fa089e204e799423e9bf01e3d2a33 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:42:09 +0200 Subject: [PATCH 05/33] refactor(MasterMix): let the compiler catch unscaled row kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default branch would have multiplied any row kind added later by the number of reactions plus pipetting loss, showing a wrong volume on a work list without a compile error. 🤖 Generated with Claude Code --- src/MasterMix/pipettingLossTableColumn.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index 5e27de6e..401cdbc1 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -99,7 +99,8 @@ export function pipettingLossTableColumn( case 'perReactionIngredient': case 'reactionTotal': return <>–; - default: + case 'masterMixIngredient': + case 'masterMixTotal': return <>{totalVolume(record, args)} µl; } }, From 8bb40704a8f4cb74167975c5d7d624d984db17f2 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:44:59 +0200 Subject: [PATCH 06/33] style(MasterMix): match the left edge of the box to the closing line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left edge used dividerColor, which is gray4 and therefore invisible against the gray3 background of the total row, so the box looked open on its left side exactly where the closing line is darkest. 🤖 Generated with Claude Code --- src/MasterMix/VolumeTable.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 2fe73081..6c92de58 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -29,7 +29,10 @@ export const VolumeTable = styled(Table)` position: relative; } - /* Overlaps the gap between the row boxes, which a border per row would leave open. */ + /* + * Overlaps the gap between the row boxes, which a border per row would leave open. + * Both edges of the box share one gray, since dividerColor vanishes on the total row. + */ .mll-ant-table-tbody > tr.${MASTER_MIX_BLOCK_ROW_CLASS} > td:first-child::before { @@ -39,7 +42,7 @@ export const VolumeTable = styled(Table)` bottom: -1px; left: 0; width: 2px; - background-color: ${(props) => props.theme.dividerColor}; + background-color: ${PALETTE.gray5}; } /* Closes the master mix, so that what follows reads as added per reaction. */ From 5c2f5170e72d5fd374f5c5a07a2daa3c77d8e8ba Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:44:59 +0200 Subject: [PATCH 07/33] refactor(MasterMix): stop exporting the pipetted mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only used by the component that renders it. 🤖 Generated with Claude Code --- src/MasterMix/MasterMixIngredientName.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MasterMix/MasterMixIngredientName.tsx b/src/MasterMix/MasterMixIngredientName.tsx index 76990da4..20df73ce 100644 --- a/src/MasterMix/MasterMixIngredientName.tsx +++ b/src/MasterMix/MasterMixIngredientName.tsx @@ -1,7 +1,7 @@ import React, { ReactNode } from 'react'; import styled from 'styled-components'; -export const PIPETTED_MARK = '✓'; +const PIPETTED_MARK = '✓'; /** * Indents the ingredient to show it is part of the master mix and reserves the space From 65ffb480b13784dc12a9d08d52a6c94641c05418 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:49:22 +0200 Subject: [PATCH 08/33] style(MasterMix): leave the card width to the surrounding layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugging the content made the card width depend on the ingredient names, so cards placed side by side in a grid no longer lined up. Capping the table keeps it from running wide without the card claiming a width of its own. 🤖 Generated with Claude Code --- src/MasterMix/index.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index 29ad5a67..44da7f20 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -21,11 +21,6 @@ export { PipettingLossFactorWithMinimum, } from './types'; -/** Hugs the table instead of stretching across whatever the surrounding layout offers. */ -const MixCard = styled(Card)` - width: fit-content; -`; - const MixContent = styled.div` max-width: 400px; `; @@ -46,7 +41,7 @@ export function MasterMix({ mode === 'recipe' ? undefined : { count, pipettingLoss, mode }; return ( - {name}{' '} @@ -61,6 +56,6 @@ export function MasterMix({ scaling={scaling} /> - + ); } From ce14bdf51b958d0f36a8a595411a8c66398bc182 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 17:54:09 +0200 Subject: [PATCH 09/33] style(MasterMix): indent the total volume with the ingredients it sums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without per reaction ingredients there is no section row to indent against, so the ingredients sat 20px right of their own total. Indenting every row the master mix consists of also delimits the block well enough that the gap before the per reaction rows became unnecessary. The predicate for those rows already existed for scaling, so both the indent and the scaling now derive from one definition of what the master mix is. 🤖 Generated with Claude Code --- ...ngredientName.tsx => MasterMixRowName.tsx} | 2 +- src/MasterMix/VolumeTable.tsx | 4 ---- src/MasterMix/volumeColumns.tsx | 19 ++++++++++++------- 3 files changed, 13 insertions(+), 12 deletions(-) rename src/MasterMix/{MasterMixIngredientName.tsx => MasterMixRowName.tsx} (93%) diff --git a/src/MasterMix/MasterMixIngredientName.tsx b/src/MasterMix/MasterMixRowName.tsx similarity index 93% rename from src/MasterMix/MasterMixIngredientName.tsx rename to src/MasterMix/MasterMixRowName.tsx index 20df73ce..87c57379 100644 --- a/src/MasterMix/MasterMixIngredientName.tsx +++ b/src/MasterMix/MasterMixRowName.tsx @@ -13,7 +13,7 @@ const MarkSlot = styled.span` color: ${(props) => props.theme.successColor}; `; -export function MasterMixIngredientName({ +export function MasterMixRowName({ pipetted, children, }: { diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 6c92de58..8107c84f 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -50,10 +50,6 @@ export const VolumeTable = styled(Table)` border-bottom: 2px solid ${PALETTE.gray5}; } - .mll-ant-table-tbody > tr.${MASTER_MIX_END_ROW_CLASS} + tr > td { - padding-top: 16px; - } - .${PIPETTED_ROW_CLASS} { color: ${PALETTE.gray6}; } diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index b453e4e1..7f03e97f 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { MasterMixIngredientName } from './MasterMixIngredientName'; +import { MasterMixRowName } from './MasterMixRowName'; import { REFERENCE_VOLUME_CLASS } from './VolumeTable'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; import { @@ -9,8 +9,11 @@ import { PipettingScaling, } from './types'; -/** Only the master mix is scaled, so all other volumes are shown for a single reaction only. */ -function isScaled(record: MasterMixTableRow): boolean { +/** + * The rows the master mix is made of, which are indented into it and are the only ones + * scaled by the number of reactions. + */ +function belongsToMasterMix(record: MasterMixTableRow): boolean { return ( record.rowKind === 'masterMixIngredient' || record.rowKind === 'masterMixTotal' @@ -25,10 +28,10 @@ export function volumeColumns( { title: 'Name', render: (_: unknown, record: MasterMixTableRow) => - record.rowKind === 'masterMixIngredient' ? ( - + belongsToMasterMix(record) ? ( + {record.title} - + ) : ( record.title ), @@ -38,7 +41,9 @@ export function volumeColumns( align: 'right', onCell: (record: MasterMixTableRow) => ({ className: - scaling && isScaled(record) ? REFERENCE_VOLUME_CLASS : undefined, + scaling && belongsToMasterMix(record) + ? REFERENCE_VOLUME_CLASS + : undefined, }), render: (_: unknown, record: MasterMixTableRow) => record.rowKind === 'masterMixSection' ? null : ( From ecedf4509fad531d7873df556ddb0fd76646d7e9 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 18:00:47 +0200 Subject: [PATCH 10/33] feat(MasterMix): name the master mix on the row that carries its volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The separate label row said what the block was, while the row below it said only Gesamtvolumen — yet that volume is what goes into each reaction, so it is the line item the label belongs on. Naming it there drops a row, puts the master mix on the same level as the other per reaction rows, and leaves every row with a volume, which collapses the row union back into one shape. 🤖 Generated with Claude Code --- src/MasterMix/MixTable.tsx | 9 +------- src/MasterMix/VolumeTable.tsx | 4 ---- src/MasterMix/mixRows.ts | 9 ++------ src/MasterMix/pipettingLossTableColumn.tsx | 7 +----- src/MasterMix/types.ts | 22 +++++++----------- src/MasterMix/volumeColumns.tsx | 27 ++++++++++++++-------- 6 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index b6071ad0..6e9d2779 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -5,7 +5,6 @@ import { MASTER_MIX_BLOCK_ROW_CLASS, MASTER_MIX_END_ROW_CLASS, PIPETTED_ROW_CLASS, - SECTION_ROW_CLASS, TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS, VolumeTable, @@ -24,12 +23,6 @@ function rowClassName( nested: boolean, ): string { switch (record.rowKind) { - case 'masterMixSection': - return [ - SECTION_ROW_CLASS, - MASTER_MIX_BLOCK_ROW_CLASS, - UNCLICKABLE_ROW_CLASS, - ].join(' '); case 'masterMixIngredient': return [ nested ? MASTER_MIX_BLOCK_ROW_CLASS : '', @@ -80,7 +73,7 @@ export function MixTable({ }, })) } - columns={volumeColumns(scaling, pipettedKeys)} + columns={volumeColumns(scaling, pipettedKeys, nested)} /> ); } diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 8107c84f..0929d049 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -6,7 +6,6 @@ import { PALETTE } from '../theme'; export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; export const MASTER_MIX_BLOCK_ROW_CLASS = 'master-mix-block-row'; export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; -export const SECTION_ROW_CLASS = 'section-row'; export const PIPETTED_ROW_CLASS = 'pipetted-row'; export const UNCLICKABLE_ROW_CLASS = 'unclickable-row'; export const REFERENCE_VOLUME_CLASS = 'reference-volume'; @@ -18,9 +17,6 @@ export const REFERENCE_VOLUME_CLASS = 'reference-volume'; export const VolumeTable = styled(Table)` .${TOTAL_VOLUME_ROW_CLASS} { background-color: ${PALETTE.gray3}; - } - - .${TOTAL_VOLUME_ROW_CLASS}, .${SECTION_ROW_CLASS} { font-weight: bold; } diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts index 5d9b29b4..658f242a 100644 --- a/src/MasterMix/mixRows.ts +++ b/src/MasterMix/mixRows.ts @@ -2,7 +2,6 @@ import { reactionVolume, sumVolume } from './reactionVolume'; import { MasterMixIngredient, MasterMixTableRow, ReactionMix } from './types'; // Free of the separator, so they can never collide with an ingredient row key. -const MASTER_MIX_SECTION_KEY = 'masterMixSection'; const MASTER_MIX_TOTAL_KEY = 'masterMixTotal'; const REACTION_TOTAL_KEY = 'reactionTotal'; @@ -29,7 +28,8 @@ export function mixRows({ }: ReactionMix): Array { const masterMixTotal: MasterMixTableRow = { key: MASTER_MIX_TOTAL_KEY, - title: 'Gesamtvolumen', + /** Within a reaction mix the total doubles as the amount of master mix per reaction. */ + title: perReactionIngredients?.length ? MASTER_MIX_LABEL : 'Gesamtvolumen', volume: sumVolume(ingredients), rowKind: 'masterMixTotal', }; @@ -46,11 +46,6 @@ export function mixRows({ } return [ - { - key: MASTER_MIX_SECTION_KEY, - title: MASTER_MIX_LABEL, - rowKind: 'masterMixSection', - }, ...masterMixIngredients, masterMixTotal, ...perReactionIngredients.map( diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index 401cdbc1..a634a6c2 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -52,10 +52,7 @@ function pipettingLossTitle( } } -function totalVolume( - record: Exclude, - args: PipettingScaling, -) { +function totalVolume(record: MasterMixTableRow, args: PipettingScaling) { switch (args.pipettingLoss.type) { case 'absolute': return (record.volume * (args.count + args.pipettingLoss.count)).toFixed( @@ -93,8 +90,6 @@ export function pipettingLossTableColumn( ), render: (_: unknown, record: MasterMixTableRow) => { switch (record.rowKind) { - case 'masterMixSection': - return null; /** Pipetted into each reaction individually, so the loss does not apply. */ case 'perReactionIngredient': case 'reactionTotal': diff --git a/src/MasterMix/types.ts b/src/MasterMix/types.ts index a6682b8b..1a909a87 100644 --- a/src/MasterMix/types.ts +++ b/src/MasterMix/types.ts @@ -48,24 +48,18 @@ export type PipettingLoss = | PipettingLossAbsolute | PipettingLossFactorWithMinimum; -/** Keyed by string, since the rows the table adds around the ingredients have no numeric key. */ -type MixTableRow = { +export type MasterMixTableRow = { + /** A string, since the total rows the table adds have no numeric key of their own. */ key: string; title: MasterMixIngredient['title']; + volume: number; + rowKind: + | 'masterMixIngredient' + | 'masterMixTotal' + | 'perReactionIngredient' + | 'reactionTotal'; }; -export type MasterMixTableRow = - | (MixTableRow & { - volume: number; - rowKind: - | 'masterMixIngredient' - | 'masterMixTotal' - | 'perReactionIngredient' - | 'reactionTotal'; - }) - /** Labels the master mix within the reaction mix, so it carries no volume of its own. */ - | (MixTableRow & { rowKind: 'masterMixSection' }); - export type PipettingLossTableColumn = Modify< ColumnType, { diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index 7f03e97f..51c724bb 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -9,10 +9,7 @@ import { PipettingScaling, } from './types'; -/** - * The rows the master mix is made of, which are indented into it and are the only ones - * scaled by the number of reactions. - */ +/** Only the master mix is mixed for all reactions at once, so only it is scaled. */ function belongsToMasterMix(record: MasterMixTableRow): boolean { return ( record.rowKind === 'masterMixIngredient' || @@ -20,15 +17,28 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { ); } +/** + * Within a reaction mix the total names the master mix, so it stays on the level of the + * other per reaction rows. On its own there is no such level to stand out from, and + * outdenting it would leave the ingredients indented against nothing. + */ +function isIndented(record: MasterMixTableRow, nested: boolean): boolean { + return ( + record.rowKind === 'masterMixIngredient' || + (!nested && record.rowKind === 'masterMixTotal') + ); +} + export function volumeColumns( scaling: PipettingScaling | undefined, pipettedKeys: Array, + nested: boolean, ): Array { return [ { title: 'Name', render: (_: unknown, record: MasterMixTableRow) => - belongsToMasterMix(record) ? ( + isIndented(record, nested) ? ( {record.title} @@ -45,10 +55,9 @@ export function volumeColumns( ? REFERENCE_VOLUME_CLASS : undefined, }), - render: (_: unknown, record: MasterMixTableRow) => - record.rowKind === 'masterMixSection' ? null : ( - <>{record.volume.toFixed(1)} µl - ), + render: (_: unknown, record: MasterMixTableRow) => ( + <>{record.volume.toFixed(1)} µl + ), }, ...(scaling ? [pipettingLossTableColumn(scaling)] : []), ]; From e17b58c090f7c6e5fbdaad0e913fcc40749a5d30 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 18:06:24 +0200 Subject: [PATCH 11/33] style(MasterMix): outdent the totals from the rows they sum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indenting the master mix total kept its own ingredients aligned with it, but broke the column the pipettor actually adds up: master mix plus template equals the reaction volume. Outdented totals also follow the convention any invoice or recipe uses, and the indent no longer depends on whether the mix is nested. 🤖 Generated with Claude Code --- src/MasterMix/MixTable.tsx | 2 +- src/MasterMix/volumeColumns.tsx | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 6e9d2779..0f7a6c64 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -73,7 +73,7 @@ export function MixTable({ }, })) } - columns={volumeColumns(scaling, pipettedKeys, nested)} + columns={volumeColumns(scaling, pipettedKeys)} /> ); } diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index 51c724bb..c3aa561e 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -17,28 +17,16 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { ); } -/** - * Within a reaction mix the total names the master mix, so it stays on the level of the - * other per reaction rows. On its own there is no such level to stand out from, and - * outdenting it would leave the ingredients indented against nothing. - */ -function isIndented(record: MasterMixTableRow, nested: boolean): boolean { - return ( - record.rowKind === 'masterMixIngredient' || - (!nested && record.rowKind === 'masterMixTotal') - ); -} - export function volumeColumns( scaling: PipettingScaling | undefined, pipettedKeys: Array, - nested: boolean, ): Array { return [ { title: 'Name', + /** Indented as parts of the total below them, which stays flush as their sum. */ render: (_: unknown, record: MasterMixTableRow) => - isIndented(record, nested) ? ( + record.rowKind === 'masterMixIngredient' ? ( {record.title} From 40fe4508958afb24928378ff464aeaf82263486a Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 18:15:25 +0200 Subject: [PATCH 12/33] style(MasterMix): indent every row one step below the total it feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indenting only the master mix ingredients could not be derived from either reading of what an indent means here, so the master mix total and the template ended up on different levels although both are added per reaction. One step per sum puts them side by side below the reaction volume, and makes the nesting itself carry what the box previously had to. 🤖 Generated with Claude Code --- src/MasterMix/MasterMixRowName.tsx | 16 +++++++++----- src/MasterMix/MixTable.tsx | 2 +- src/MasterMix/volumeColumns.tsx | 35 ++++++++++++++++++++++-------- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/MasterMix/MasterMixRowName.tsx b/src/MasterMix/MasterMixRowName.tsx index 87c57379..15920023 100644 --- a/src/MasterMix/MasterMixRowName.tsx +++ b/src/MasterMix/MasterMixRowName.tsx @@ -2,29 +2,33 @@ import React, { ReactNode } from 'react'; import styled from 'styled-components'; const PIPETTED_MARK = '✓'; +const INDENT_STEP_IN_PIXELS = 20; /** - * Indents the ingredient to show it is part of the master mix and reserves the space - * for the mark, so checking one off does not shift the layout. + * Indents by one step per sum the row contributes to, and holds the mark right before the + * name so that checking one off neither shifts the layout nor detaches from its row. */ -const MarkSlot = styled.span` +const Indent = styled.span<{ $level: number }>` display: inline-block; - width: 20px; + width: ${(props) => props.$level * INDENT_STEP_IN_PIXELS}px; + text-align: right; color: ${(props) => props.theme.successColor}; `; export function MasterMixRowName({ + level, pipetted, children, }: { + level: number; pipetted: boolean; children: ReactNode; }) { return ( <> - + {pipetted ? PIPETTED_MARK : null} - + {children} ); diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 0f7a6c64..6e9d2779 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -73,7 +73,7 @@ export function MixTable({ }, })) } - columns={volumeColumns(scaling, pipettedKeys)} + columns={volumeColumns(scaling, pipettedKeys, nested)} /> ); } diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index c3aa561e..3f7ca4e4 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -17,22 +17,39 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { ); } +/** + * Every row is indented one step further than the total it is a summand of, so the master + * mix and the ingredients added per reaction meet on the level of the reaction volume. + */ +function indentLevel(record: MasterMixTableRow, nested: boolean): number { + switch (record.rowKind) { + case 'masterMixIngredient': + return nested ? 2 : 1; + case 'masterMixTotal': + return nested ? 1 : 0; + case 'perReactionIngredient': + return 1; + case 'reactionTotal': + return 0; + } +} + export function volumeColumns( scaling: PipettingScaling | undefined, pipettedKeys: Array, + nested: boolean, ): Array { return [ { title: 'Name', - /** Indented as parts of the total below them, which stays flush as their sum. */ - render: (_: unknown, record: MasterMixTableRow) => - record.rowKind === 'masterMixIngredient' ? ( - - {record.title} - - ) : ( - record.title - ), + render: (_: unknown, record: MasterMixTableRow) => ( + + {record.title} + + ), }, { title: scaling ? '1x' : 'Volumen', From 554ccb4d30d899419f60899d191db8d3713c470c Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 18:22:12 +0200 Subject: [PATCH 13/33] style(MasterMix): let the indentation carry the nesting alone The left edge bracketed the master mix from outside the block it enclosed, which the indentation already expresses. --- src/MasterMix/MixTable.tsx | 7 +------ src/MasterMix/VolumeTable.tsx | 22 ---------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 6e9d2779..9464ff13 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -2,7 +2,6 @@ import { toggleElement } from '@mll-lab/js-utils'; import React, { useState } from 'react'; import { - MASTER_MIX_BLOCK_ROW_CLASS, MASTER_MIX_END_ROW_CLASS, PIPETTED_ROW_CLASS, TOTAL_VOLUME_ROW_CLASS, @@ -24,15 +23,11 @@ function rowClassName( ): string { switch (record.rowKind) { case 'masterMixIngredient': - return [ - nested ? MASTER_MIX_BLOCK_ROW_CLASS : '', - pipettedKeys.includes(record.key) ? PIPETTED_ROW_CLASS : '', - ].join(' '); + return pipettedKeys.includes(record.key) ? PIPETTED_ROW_CLASS : ''; case 'masterMixTotal': return [ TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS, - nested ? MASTER_MIX_BLOCK_ROW_CLASS : '', nested ? MASTER_MIX_END_ROW_CLASS : '', ].join(' '); case 'perReactionIngredient': diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 0929d049..dcc60b1b 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -4,7 +4,6 @@ import { Table } from '../Table'; import { PALETTE } from '../theme'; export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; -export const MASTER_MIX_BLOCK_ROW_CLASS = 'master-mix-block-row'; export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; export const PIPETTED_ROW_CLASS = 'pipetted-row'; export const UNCLICKABLE_ROW_CLASS = 'unclickable-row'; @@ -20,27 +19,6 @@ export const VolumeTable = styled(Table)` font-weight: bold; } - /* Left edge of the box that holds the master mix, from its label down to its total. */ - .mll-ant-table-tbody > tr.${MASTER_MIX_BLOCK_ROW_CLASS} > td:first-child { - position: relative; - } - - /* - * Overlaps the gap between the row boxes, which a border per row would leave open. - * Both edges of the box share one gray, since dividerColor vanishes on the total row. - */ - .mll-ant-table-tbody - > tr.${MASTER_MIX_BLOCK_ROW_CLASS} - > td:first-child::before { - content: ''; - position: absolute; - top: -1px; - bottom: -1px; - left: 0; - width: 2px; - background-color: ${PALETTE.gray5}; - } - /* Closes the master mix, so that what follows reads as added per reaction. */ .mll-ant-table-tbody > tr.${MASTER_MIX_END_ROW_CLASS} > td { border-bottom: 2px solid ${PALETTE.gray5}; From f2639d7f25fa94b215e3b960539b885d69e4fa3d Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 18:25:46 +0200 Subject: [PATCH 14/33] style(MasterMix): set the pipetted mark off from the name --- src/MasterMix/MasterMixRowName.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/MasterMix/MasterMixRowName.tsx b/src/MasterMix/MasterMixRowName.tsx index 15920023..0a98e1fc 100644 --- a/src/MasterMix/MasterMixRowName.tsx +++ b/src/MasterMix/MasterMixRowName.tsx @@ -3,13 +3,17 @@ import styled from 'styled-components'; const PIPETTED_MARK = '✓'; const INDENT_STEP_IN_PIXELS = 20; +const MARK_GAP_IN_PIXELS = 5; /** * Indents by one step per sum the row contributes to, and holds the mark right before the * name so that checking one off neither shifts the layout nor detaches from its row. + * The offset separates the mark from the name without moving the name on any level. */ const Indent = styled.span<{ $level: number }>` display: inline-block; + position: relative; + right: ${MARK_GAP_IN_PIXELS}px; width: ${(props) => props.$level * INDENT_STEP_IN_PIXELS}px; text-align: right; color: ${(props) => props.theme.successColor}; From 306bcb1804996d27e392022897c062a89125a44d Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 19:25:58 +0200 Subject: [PATCH 15/33] fix(MasterMix): derive the scaling from the data instead of the mode A caller without types that passes no pipetting loss now falls back to the recipe view instead of dereferencing it. --- src/MasterMix/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index 44da7f20..392c8555 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -31,14 +31,13 @@ const MixContent = styled.div` */ export function MasterMix({ name, - mode, count, pipettingLoss, ingredients, perReactionIngredients, }: MasterMixProps) { - const scaling = - mode === 'recipe' ? undefined : { count, pipettingLoss, mode }; + /** Keyed on the scaling data itself, so a caller without types degrades to the recipe. */ + const scaling = pipettingLoss != null ? { count, pipettingLoss } : undefined; return ( Date: Thu, 30 Jul 2026 19:38:16 +0200 Subject: [PATCH 16/33] refactor(MasterMix): sum volumes with lodash --- src/MasterMix/reactionVolume.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/MasterMix/reactionVolume.ts b/src/MasterMix/reactionVolume.ts index aa7780a1..bc6f61c7 100644 --- a/src/MasterMix/reactionVolume.ts +++ b/src/MasterMix/reactionVolume.ts @@ -1,10 +1,9 @@ +import { sumBy } from 'lodash'; + import { MasterMixIngredient, ReactionMix } from './types'; export function sumVolume(ingredients: Array): number { - return ingredients.reduce( - (volumeAccumulator, ingredient) => volumeAccumulator + ingredient.volume, - 0, - ); + return sumBy(ingredients, (ingredient) => ingredient.volume); } /** From 84a992d4a5828944a1e1ad75f98f97300af46ff4 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 19:38:16 +0200 Subject: [PATCH 17/33] refactor(MasterMix): assemble the row classes with insertIf --- src/MasterMix/MixTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 9464ff13..5d8fb0d0 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -1,4 +1,4 @@ -import { toggleElement } from '@mll-lab/js-utils'; +import { insertIf, toggleElement } from '@mll-lab/js-utils'; import React, { useState } from 'react'; import { @@ -12,7 +12,7 @@ import { mixRows } from './mixRows'; import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types'; import { volumeColumns } from './volumeColumns'; -export type MixTableProps = ReactionMix & { +type MixTableProps = ReactionMix & { scaling?: PipettingScaling; }; @@ -28,7 +28,7 @@ function rowClassName( return [ TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS, - nested ? MASTER_MIX_END_ROW_CLASS : '', + ...insertIf(nested, MASTER_MIX_END_ROW_CLASS), ].join(' '); case 'perReactionIngredient': return UNCLICKABLE_ROW_CLASS; From 88e3062080bd99a212e396a84349ef8afc56f8d3 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 19:38:16 +0200 Subject: [PATCH 18/33] refactor(MasterMix): inline names that are used once --- src/MasterMix/mixRows.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts index 658f242a..657c52c8 100644 --- a/src/MasterMix/mixRows.ts +++ b/src/MasterMix/mixRows.ts @@ -1,15 +1,12 @@ import { reactionVolume, sumVolume } from './reactionVolume'; import { MasterMixIngredient, MasterMixTableRow, ReactionMix } from './types'; -// Free of the separator, so they can never collide with an ingredient row key. -const MASTER_MIX_TOTAL_KEY = 'masterMixTotal'; -const REACTION_TOTAL_KEY = 'reactionTotal'; - export const MASTER_MIX_LABEL = 'MasterMix'; /** * Both ingredient lists are keyed by the consumer and typically start at 1, - * so the row kind namespaces otherwise colliding keys apart. + * so the row kind namespaces otherwise colliding keys apart. The keys of the + * total rows below carry no separator and can therefore never collide with these. */ function ingredientRowKey( rowKind: 'masterMixIngredient' | 'perReactionIngredient', @@ -27,7 +24,7 @@ export function mixRows({ perReactionIngredients, }: ReactionMix): Array { const masterMixTotal: MasterMixTableRow = { - key: MASTER_MIX_TOTAL_KEY, + key: 'masterMixTotal', /** Within a reaction mix the total doubles as the amount of master mix per reaction. */ title: perReactionIngredients?.length ? MASTER_MIX_LABEL : 'Gesamtvolumen', volume: sumVolume(ingredients), @@ -56,7 +53,7 @@ export function mixRows({ }), ), { - key: REACTION_TOTAL_KEY, + key: 'reactionTotal', title: 'Reaktionsvolumen', volume: reactionVolume({ ingredients, perReactionIngredients }), rowKind: 'reactionTotal', From 7588c7de14f8bc1fb833062e5ca4df267ab0c83c Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:34:58 +0200 Subject: [PATCH 19/33] refactor(MasterMix): name the question whether anything is added per reaction The predicate appeared verbatim at four sites in three files, each time in a different disguise. Naming it also names what "nested" left implicit. --- src/MasterMix/MixTable.tsx | 11 ++++++----- src/MasterMix/hasPerReactionIngredients.ts | 8 ++++++++ src/MasterMix/index.tsx | 5 ++++- src/MasterMix/mixRows.ts | 7 +++++-- src/MasterMix/volumeColumns.tsx | 13 ++++++++----- 5 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/MasterMix/hasPerReactionIngredients.ts diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 5d8fb0d0..3c943f7e 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -8,6 +8,7 @@ import { UNCLICKABLE_ROW_CLASS, VolumeTable, } from './VolumeTable'; +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; import { mixRows } from './mixRows'; import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types'; import { volumeColumns } from './volumeColumns'; @@ -19,7 +20,7 @@ type MixTableProps = ReactionMix & { function rowClassName( record: MasterMixTableRow, pipettedKeys: Array, - nested: boolean, + withinReactionMix: boolean, ): string { switch (record.rowKind) { case 'masterMixIngredient': @@ -28,7 +29,7 @@ function rowClassName( return [ TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS, - ...insertIf(nested, MASTER_MIX_END_ROW_CLASS), + ...insertIf(withinReactionMix, MASTER_MIX_END_ROW_CLASS), ].join(' '); case 'perReactionIngredient': return UNCLICKABLE_ROW_CLASS; @@ -45,7 +46,7 @@ export function MixTable({ }: MixTableProps) { const [pipettedKeys, setPipettedKeys] = useState>([]); - const nested = Boolean(perReactionIngredients?.length); + const withinReactionMix = hasPerReactionIngredients(perReactionIngredients); return ( record.key} pagination={false} rowClassName={(record: MasterMixTableRow) => - rowClassName(record, pipettedKeys, nested) + rowClassName(record, pipettedKeys, withinReactionMix) } onRow={ scaling && @@ -68,7 +69,7 @@ export function MixTable({ }, })) } - columns={volumeColumns(scaling, pipettedKeys, nested)} + columns={volumeColumns(scaling, pipettedKeys, withinReactionMix)} /> ); } diff --git a/src/MasterMix/hasPerReactionIngredients.ts b/src/MasterMix/hasPerReactionIngredients.ts new file mode 100644 index 00000000..f8076c4e --- /dev/null +++ b/src/MasterMix/hasPerReactionIngredients.ts @@ -0,0 +1,8 @@ +import { MasterMixIngredient, ReactionMix } from './types'; + +/** Without them, the reaction mix consists of nothing but the master mix. */ +export function hasPerReactionIngredients( + perReactionIngredients: ReactionMix['perReactionIngredients'], +): perReactionIngredients is Array { + return Boolean(perReactionIngredients?.length); +} diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index 392c8555..e07e271b 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -5,6 +5,7 @@ import { Card } from '../Card'; import { Typography } from '../Typography'; import { MixTable } from './MixTable'; +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; import { MASTER_MIX_LABEL } from './mixRows'; import { MasterMixProps } from './types'; @@ -44,7 +45,9 @@ export function MasterMix({ title={ {name}{' '} - {perReactionIngredients?.length ? 'Reaktionsmix' : MASTER_MIX_LABEL} + {hasPerReactionIngredients(perReactionIngredients) + ? 'Reaktionsmix' + : MASTER_MIX_LABEL} } > diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts index 657c52c8..1543ce50 100644 --- a/src/MasterMix/mixRows.ts +++ b/src/MasterMix/mixRows.ts @@ -1,3 +1,4 @@ +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; import { reactionVolume, sumVolume } from './reactionVolume'; import { MasterMixIngredient, MasterMixTableRow, ReactionMix } from './types'; @@ -26,7 +27,9 @@ export function mixRows({ const masterMixTotal: MasterMixTableRow = { key: 'masterMixTotal', /** Within a reaction mix the total doubles as the amount of master mix per reaction. */ - title: perReactionIngredients?.length ? MASTER_MIX_LABEL : 'Gesamtvolumen', + title: hasPerReactionIngredients(perReactionIngredients) + ? MASTER_MIX_LABEL + : 'Gesamtvolumen', volume: sumVolume(ingredients), rowKind: 'masterMixTotal', }; @@ -38,7 +41,7 @@ export function mixRows({ }), ); - if (!perReactionIngredients?.length) { + if (!hasPerReactionIngredients(perReactionIngredients)) { return [...masterMixIngredients, masterMixTotal]; } diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index 3f7ca4e4..926f0aa9 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -21,12 +21,15 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { * Every row is indented one step further than the total it is a summand of, so the master * mix and the ingredients added per reaction meet on the level of the reaction volume. */ -function indentLevel(record: MasterMixTableRow, nested: boolean): number { +function indentLevel( + record: MasterMixTableRow, + withinReactionMix: boolean, +): number { switch (record.rowKind) { case 'masterMixIngredient': - return nested ? 2 : 1; + return withinReactionMix ? 2 : 1; case 'masterMixTotal': - return nested ? 1 : 0; + return withinReactionMix ? 1 : 0; case 'perReactionIngredient': return 1; case 'reactionTotal': @@ -37,14 +40,14 @@ function indentLevel(record: MasterMixTableRow, nested: boolean): number { export function volumeColumns( scaling: PipettingScaling | undefined, pipettedKeys: Array, - nested: boolean, + withinReactionMix: boolean, ): Array { return [ { title: 'Name', render: (_: unknown, record: MasterMixTableRow) => ( {record.title} From fbeeb38f8ab5836ef87d815b97a9f5cba19052f5 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:35:37 +0200 Subject: [PATCH 20/33] refactor(MasterMix): assemble the master mix rows once Both return branches spelled out the same prefix. --- src/MasterMix/mixRows.ts | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts index 1543ce50..4b39fe14 100644 --- a/src/MasterMix/mixRows.ts +++ b/src/MasterMix/mixRows.ts @@ -24,30 +24,31 @@ export function mixRows({ ingredients, perReactionIngredients, }: ReactionMix): Array { - const masterMixTotal: MasterMixTableRow = { - key: 'masterMixTotal', - /** Within a reaction mix the total doubles as the amount of master mix per reaction. */ - title: hasPerReactionIngredients(perReactionIngredients) - ? MASTER_MIX_LABEL - : 'Gesamtvolumen', - volume: sumVolume(ingredients), - rowKind: 'masterMixTotal', - }; - const masterMixIngredients: Array = ingredients.map( - (ingredient) => ({ - ...ingredient, - key: ingredientRowKey('masterMixIngredient', ingredient), - rowKind: 'masterMixIngredient', - }), - ); + const masterMixRows: Array = [ + ...ingredients.map( + (ingredient): MasterMixTableRow => ({ + ...ingredient, + key: ingredientRowKey('masterMixIngredient', ingredient), + rowKind: 'masterMixIngredient', + }), + ), + { + key: 'masterMixTotal', + /** Within a reaction mix the total doubles as the amount of master mix per reaction. */ + title: hasPerReactionIngredients(perReactionIngredients) + ? MASTER_MIX_LABEL + : 'Gesamtvolumen', + volume: sumVolume(ingredients), + rowKind: 'masterMixTotal', + }, + ]; if (!hasPerReactionIngredients(perReactionIngredients)) { - return [...masterMixIngredients, masterMixTotal]; + return masterMixRows; } return [ - ...masterMixIngredients, - masterMixTotal, + ...masterMixRows, ...perReactionIngredients.map( (ingredient): MasterMixTableRow => ({ ...ingredient, From baf3c4fb944be98124fe05243517e75b300dadbf Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:35:53 +0200 Subject: [PATCH 21/33] refactor(MasterMix): keep the scaling mode types internal Both arms of the union are new and unreleased, and MasterMixProps is enough to annotate a call site. Removing them after the release would be breaking. --- src/MasterMix/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index e07e271b..b14c27cd 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -14,8 +14,6 @@ export { MasterMixProps, MasterMixIngredient, ReactionMix, - PipettingScaling, - RecipeMode, PipettingLoss, PipettingLossAbsolute, PipettingLossByFactor, From c95c091c3a8b806841c3b26bee81e530b8f1a1aa Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:37:12 +0200 Subject: [PATCH 22/33] refactor(Table): derive the pointer cursor per row The cursor was decided for the whole table by calling onRow with an empty object, which lies to a callback that is typed for a record and only worked because no caller dereferenced it. Deriving it from the row props each row actually returns drops the workaround MasterMix needed to opt single rows out. --- src/MasterMix/MixTable.tsx | 25 +++++++++++-------------- src/MasterMix/VolumeTable.tsx | 6 ------ src/Table/index.tsx | 29 +++++++++++++++++------------ 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 3c943f7e..271f4391 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -5,7 +5,6 @@ import { MASTER_MIX_END_ROW_CLASS, PIPETTED_ROW_CLASS, TOTAL_VOLUME_ROW_CLASS, - UNCLICKABLE_ROW_CLASS, VolumeTable, } from './VolumeTable'; import { hasPerReactionIngredients } from './hasPerReactionIngredients'; @@ -28,13 +27,12 @@ function rowClassName( case 'masterMixTotal': return [ TOTAL_VOLUME_ROW_CLASS, - UNCLICKABLE_ROW_CLASS, ...insertIf(withinReactionMix, MASTER_MIX_END_ROW_CLASS), ].join(' '); case 'perReactionIngredient': - return UNCLICKABLE_ROW_CLASS; + return ''; case 'reactionTotal': - return [TOTAL_VOLUME_ROW_CLASS, UNCLICKABLE_ROW_CLASS].join(' '); + return TOTAL_VOLUME_ROW_CLASS; } } @@ -58,16 +56,15 @@ export function MixTable({ } onRow={ scaling && - ((record: MasterMixTableRow) => ({ - onClick: () => { - if (record.rowKind !== 'masterMixIngredient') { - return; - } - setPipettedKeys((previouslyPipetted) => - toggleElement(previouslyPipetted, record.key), - ); - }, - })) + ((record: MasterMixTableRow) => + record.rowKind === 'masterMixIngredient' + ? { + onClick: () => + setPipettedKeys((previouslyPipetted) => + toggleElement(previouslyPipetted, record.key), + ), + } + : {}) } columns={volumeColumns(scaling, pipettedKeys, withinReactionMix)} /> diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index dcc60b1b..5d668483 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -6,7 +6,6 @@ import { PALETTE } from '../theme'; export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; export const PIPETTED_ROW_CLASS = 'pipetted-row'; -export const UNCLICKABLE_ROW_CLASS = 'unclickable-row'; export const REFERENCE_VOLUME_CLASS = 'reference-volume'; /** @@ -32,9 +31,4 @@ export const VolumeTable = styled(Table)` .${REFERENCE_VOLUME_CLASS} { color: ${PALETTE.gray6}; } - - /* Table sets a pointer cursor on every row as soon as any row is clickable. */ - .${UNCLICKABLE_ROW_CLASS}:hover { - cursor: default; - } `; diff --git a/src/Table/index.tsx b/src/Table/index.tsx index fc356a9d..2d38cd20 100644 --- a/src/Table/index.tsx +++ b/src/Table/index.tsx @@ -36,27 +36,32 @@ const StyledTable = styled(AntdTable)` /* !important is necessary because antd sets the z-index to 9999 via the style attribute */ z-index: 990 !important; } - - ${(props) => - // @ts-expect-error TODO it seems unsafe to pass empty data to onRow? - props.onRow?.({})?.onClick - ? ` - tbody tr:hover { - cursor: pointer; - } - ` - : ''} ` as = Record>( props: TableProps, ) => ReactElement; export function Table< RecordType extends Record = Record, ->({ loading, ...rest }: TableProps) { +>({ loading, onRow, ...rest }: TableProps) { return ( - rowKey="id" {...rest} + onRow={ + onRow && + ((record, index) => { + const rowProps = onRow(record, index); + + /** Only rows that actually react to a click show that they can be clicked. */ + return { + ...rowProps, + style: { + cursor: rowProps.onClick ? 'pointer' : undefined, + ...rowProps.style, + }, + }; + }) + } loading={ typeof loading === 'object' ? { From bc2718649aa34a2464ca86a51f2d805a6415c397 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:38:29 +0200 Subject: [PATCH 23/33] test(MasterMix): cover the row order and the indentation levels Both rules were only reachable through a rendered table, so nothing would turn red if the nesting were flattened or the row keys collided again. --- src/MasterMix/indentLevel.test.ts | 48 ++++++++++++++++++ src/MasterMix/indentLevel.ts | 21 ++++++++ src/MasterMix/mixRows.test.ts | 81 +++++++++++++++++++++++++++++++ src/MasterMix/volumeColumns.tsx | 21 +------- 4 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 src/MasterMix/indentLevel.test.ts create mode 100644 src/MasterMix/indentLevel.ts create mode 100644 src/MasterMix/mixRows.test.ts diff --git a/src/MasterMix/indentLevel.test.ts b/src/MasterMix/indentLevel.test.ts new file mode 100644 index 00000000..f9500269 --- /dev/null +++ b/src/MasterMix/indentLevel.test.ts @@ -0,0 +1,48 @@ +import { indentLevel } from './indentLevel'; +import { MasterMixTableRow } from './types'; + +const MASTER_MIX_INGREDIENT_ROW: MasterMixTableRow = { + key: 'masterMixIngredient-1', + title: 'Water', + volume: 13, + rowKind: 'masterMixIngredient', +}; +const MASTER_MIX_TOTAL_ROW: MasterMixTableRow = { + key: 'masterMixTotal', + title: 'MasterMix', + volume: 13, + rowKind: 'masterMixTotal', +}; +const PER_REACTION_INGREDIENT_ROW: MasterMixTableRow = { + key: 'perReactionIngredient-1', + title: 'cDNA', + volume: 5, + rowKind: 'perReactionIngredient', +}; +const REACTION_TOTAL_ROW: MasterMixTableRow = { + key: 'reactionTotal', + title: 'Reaktionsvolumen', + volume: 18, + rowKind: 'reactionTotal', +}; + +describe('indentLevel', () => { + it('indents the ingredients one step below the total they sum up to', () => { + expect(indentLevel(MASTER_MIX_INGREDIENT_ROW, false)).toBe(1); + expect(indentLevel(MASTER_MIX_TOTAL_ROW, false)).toBe(0); + }); + + it('sinks the master mix one step deeper within a reaction mix', () => { + expect(indentLevel(MASTER_MIX_INGREDIENT_ROW, true)).toBe(2); + expect(indentLevel(MASTER_MIX_TOTAL_ROW, true)).toBe(1); + }); + + it('meets the master mix and the per reaction ingredients on one level', () => { + expect(indentLevel(MASTER_MIX_TOTAL_ROW, true)).toBe(1); + expect(indentLevel(PER_REACTION_INGREDIENT_ROW, true)).toBe(1); + }); + + it('leaves the reaction volume flush with the table', () => { + expect(indentLevel(REACTION_TOTAL_ROW, true)).toBe(0); + }); +}); diff --git a/src/MasterMix/indentLevel.ts b/src/MasterMix/indentLevel.ts new file mode 100644 index 00000000..b63c7894 --- /dev/null +++ b/src/MasterMix/indentLevel.ts @@ -0,0 +1,21 @@ +import { MasterMixTableRow } from './types'; + +/** + * Every row is indented one step further than the total it is a summand of, so the master + * mix and the ingredients added per reaction meet on the level of the reaction volume. + */ +export function indentLevel( + record: MasterMixTableRow, + withinReactionMix: boolean, +): number { + switch (record.rowKind) { + case 'masterMixIngredient': + return withinReactionMix ? 2 : 1; + case 'masterMixTotal': + return withinReactionMix ? 1 : 0; + case 'perReactionIngredient': + return 1; + case 'reactionTotal': + return 0; + } +} diff --git a/src/MasterMix/mixRows.test.ts b/src/MasterMix/mixRows.test.ts new file mode 100644 index 00000000..ac14b9c0 --- /dev/null +++ b/src/MasterMix/mixRows.test.ts @@ -0,0 +1,81 @@ +import { mixRows } from './mixRows'; + +describe('mixRows', () => { + it('appends the total to the ingredients it sums up', () => { + expect( + mixRows({ + ingredients: [ + { key: 1, title: 'Water', volume: 13 }, + { key: 2, title: 'Primer', volume: 2 }, + ], + }), + ).toStrictEqual([ + { + key: 'masterMixIngredient-1', + title: 'Water', + volume: 13, + rowKind: 'masterMixIngredient', + }, + { + key: 'masterMixIngredient-2', + title: 'Primer', + volume: 2, + rowKind: 'masterMixIngredient', + }, + { + key: 'masterMixTotal', + title: 'Gesamtvolumen', + volume: 15, + rowKind: 'masterMixTotal', + }, + ]); + }); + + it('closes a reaction mix with the volume of a single reaction', () => { + expect( + mixRows({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + perReactionIngredients: [{ key: 2, title: 'cDNA', volume: 5 }], + }), + ).toStrictEqual([ + { + key: 'masterMixIngredient-1', + title: 'Water', + volume: 13, + rowKind: 'masterMixIngredient', + }, + { + key: 'masterMixTotal', + title: 'MasterMix', + volume: 13, + rowKind: 'masterMixTotal', + }, + { + key: 'perReactionIngredient-2', + title: 'cDNA', + volume: 5, + rowKind: 'perReactionIngredient', + }, + { + key: 'reactionTotal', + title: 'Reaktionsvolumen', + volume: 18, + rowKind: 'reactionTotal', + }, + ]); + }); + + it('keeps the rows apart when both ingredient lists start at the same key', () => { + expect( + mixRows({ + ingredients: [{ key: 1, title: 'Water', volume: 13 }], + perReactionIngredients: [{ key: 1, title: 'cDNA', volume: 5 }], + }).map((row) => row.key), + ).toStrictEqual([ + 'masterMixIngredient-1', + 'masterMixTotal', + 'perReactionIngredient-1', + 'reactionTotal', + ]); + }); +}); diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index 926f0aa9..31f49218 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { MasterMixRowName } from './MasterMixRowName'; import { REFERENCE_VOLUME_CLASS } from './VolumeTable'; +import { indentLevel } from './indentLevel'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; import { MasterMixTableRow, @@ -17,26 +18,6 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { ); } -/** - * Every row is indented one step further than the total it is a summand of, so the master - * mix and the ingredients added per reaction meet on the level of the reaction volume. - */ -function indentLevel( - record: MasterMixTableRow, - withinReactionMix: boolean, -): number { - switch (record.rowKind) { - case 'masterMixIngredient': - return withinReactionMix ? 2 : 1; - case 'masterMixTotal': - return withinReactionMix ? 1 : 0; - case 'perReactionIngredient': - return 1; - case 'reactionTotal': - return 0; - } -} - export function volumeColumns( scaling: PipettingScaling | undefined, pipettedKeys: Array, From 4299801bf782a0384bc82d0d4db838e33c723008 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:38:44 +0200 Subject: [PATCH 24/33] docs(MasterMix): explain the width where it is set --- src/MasterMix/VolumeTable.tsx | 4 ---- src/MasterMix/index.tsx | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 5d668483..1ca06860 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -8,10 +8,6 @@ export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; export const PIPETTED_ROW_CLASS = 'pipetted-row'; export const REFERENCE_VOLUME_CLASS = 'reference-volume'; -/** - * The width is set by the surrounding container, since antd wins the specificity tie for - * `max-width` on `.mll-ant-table-wrapper`. - */ export const VolumeTable = styled(Table)` .${TOTAL_VOLUME_ROW_CLASS} { background-color: ${PALETTE.gray3}; diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index b14c27cd..e7391be3 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -20,6 +20,10 @@ export { PipettingLossFactorWithMinimum, } from './types'; +/** + * Constrains the table from the outside, since antd wins the specificity tie for + * `max-width` on `.mll-ant-table-wrapper`. + */ const MixContent = styled.div` max-width: 400px; `; From 5b6999351e969c4e9910fc9bd942c8d2edb9d5b1 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:44:43 +0200 Subject: [PATCH 25/33] refactor(MasterMix): close a mix with the line of the total above it The closing line exists exactly when rows follow the total, which the selector can say by itself. Deciding it in the row class instead required threading the nesting through rowClassName, and made the two total rows differ where they do not. --- src/MasterMix/MixTable.tsx | 14 ++++---------- src/MasterMix/VolumeTable.tsx | 5 ++--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 271f4391..18786098 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -1,8 +1,7 @@ -import { insertIf, toggleElement } from '@mll-lab/js-utils'; +import { toggleElement } from '@mll-lab/js-utils'; import React, { useState } from 'react'; import { - MASTER_MIX_END_ROW_CLASS, PIPETTED_ROW_CLASS, TOTAL_VOLUME_ROW_CLASS, VolumeTable, @@ -19,20 +18,15 @@ type MixTableProps = ReactionMix & { function rowClassName( record: MasterMixTableRow, pipettedKeys: Array, - withinReactionMix: boolean, ): string { switch (record.rowKind) { case 'masterMixIngredient': return pipettedKeys.includes(record.key) ? PIPETTED_ROW_CLASS : ''; case 'masterMixTotal': - return [ - TOTAL_VOLUME_ROW_CLASS, - ...insertIf(withinReactionMix, MASTER_MIX_END_ROW_CLASS), - ].join(' '); - case 'perReactionIngredient': - return ''; case 'reactionTotal': return TOTAL_VOLUME_ROW_CLASS; + case 'perReactionIngredient': + return ''; } } @@ -52,7 +46,7 @@ export function MixTable({ rowKey={(record: MasterMixTableRow) => record.key} pagination={false} rowClassName={(record: MasterMixTableRow) => - rowClassName(record, pipettedKeys, withinReactionMix) + rowClassName(record, pipettedKeys) } onRow={ scaling && diff --git a/src/MasterMix/VolumeTable.tsx b/src/MasterMix/VolumeTable.tsx index 1ca06860..0455c462 100644 --- a/src/MasterMix/VolumeTable.tsx +++ b/src/MasterMix/VolumeTable.tsx @@ -4,7 +4,6 @@ import { Table } from '../Table'; import { PALETTE } from '../theme'; export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; -export const MASTER_MIX_END_ROW_CLASS = 'master-mix-end-row'; export const PIPETTED_ROW_CLASS = 'pipetted-row'; export const REFERENCE_VOLUME_CLASS = 'reference-volume'; @@ -14,8 +13,8 @@ export const VolumeTable = styled(Table)` font-weight: bold; } - /* Closes the master mix, so that what follows reads as added per reaction. */ - .mll-ant-table-tbody > tr.${MASTER_MIX_END_ROW_CLASS} > td { + /* A total with rows below it closes a mix that those rows add to. */ + .mll-ant-table-tbody > tr.${TOTAL_VOLUME_ROW_CLASS}:not(:last-child) > td { border-bottom: 2px solid ${PALETTE.gray5}; } From 2d861978120e53279565035c2e45d4431afcfc1c Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 20:45:31 +0200 Subject: [PATCH 26/33] refactor(MasterMix): address the row key by name Table defaults to "id", so the field has to be named, but not through a closure. --- src/MasterMix/MixTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index 18786098..fb0e7225 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -43,7 +43,7 @@ export function MixTable({ return ( record.key} + rowKey="key" pagination={false} rowClassName={(record: MasterMixTableRow) => rowClassName(record, pipettedKeys) From 99dad1451d280cfcbfc360d752876f69e3710dee Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 27/33] test(MasterMix): drop the row key assertion the unit test already covers --- src/MasterMix/MasterMix.test.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index 94aef11c..c86c71bf 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -79,31 +79,6 @@ describe('MasterMix', () => { expect(screen.getAllByText('–')).toHaveLength(2); }); - it('keeps the rows apart when both ingredient lists use the same key', () => { - render( - , - ); - - /* - * The row key is only observable through the attribute antd renders it into, - * which no user-facing query can reach. - */ - /* eslint-disable testing-library/no-node-access */ - expect( - document.querySelector('[data-row-key="masterMixIngredient-1"]'), - ).toBeInTheDocument(); - expect( - document.querySelector('[data-row-key="perReactionIngredient-1"]'), - ).toBeInTheDocument(); - /* eslint-enable testing-library/no-node-access */ - }); - it('omits the reaction volume without per reaction ingredients', () => { render( Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 28/33] test(MasterMix): let the pipetting loss test names carry the loss type --- .../pipettingLossTableColumn.test.tsx | 174 +++++++++--------- 1 file changed, 84 insertions(+), 90 deletions(-) diff --git a/src/MasterMix/pipettingLossTableColumn.test.tsx b/src/MasterMix/pipettingLossTableColumn.test.tsx index fcefa4d0..bace2a23 100644 --- a/src/MasterMix/pipettingLossTableColumn.test.tsx +++ b/src/MasterMix/pipettingLossTableColumn.test.tsx @@ -4,105 +4,99 @@ import React from 'react'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; describe('pipettingLossTableColumn', () => { - describe('with "absolute" pipetting loss type', () => { - it('adds the absolute loss to the scaled volume', () => { - const column = pipettingLossTableColumn({ - count: 2, - pipettingLoss: { type: 'absolute', count: 1 }, - }); - render( - <> - {column.render( - null, - { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, - 1, - )} - , - ); - render(<>{column.title}); - - expect(screen.getByText('30.0 µl')).toBeInTheDocument(); - expect(screen.getByText('2x Ansätze + 1x (PV)')).toBeInTheDocument(); + it('adds the absolute loss to the scaled volume', () => { + const column = pipettingLossTableColumn({ + count: 2, + pipettingLoss: { type: 'absolute', count: 1 }, }); - }); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); + render(<>{column.title}); - describe('with "factor" pipetting loss type', () => { - it('adds the loss factor to the scaled volume', () => { - const column = pipettingLossTableColumn({ - count: 2, - pipettingLoss: { type: 'factor', factor: 0.1 }, - }); - render( - <> - {column.render( - null, - { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, - 1, - )} - , - ); - render(<>{column.title}); + expect(screen.getByText('30.0 µl')).toBeInTheDocument(); + expect(screen.getByText('2x Ansätze + 1x (PV)')).toBeInTheDocument(); + }); - expect(screen.getByText('30.0 µl')).toBeInTheDocument(); - expect(screen.getByText('2x Ansätze + 10% (PV)')).toBeInTheDocument(); + it('adds the loss factor to the scaled volume', () => { + const column = pipettingLossTableColumn({ + count: 2, + pipettingLoss: { type: 'factor', factor: 0.1 }, }); - }); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); + render(<>{column.title}); - describe('with "factorWithMinimum" pipetting loss type', () => { - it('uses the minimum positions when the factor loss stays below them', () => { - // 19 ansätze × 10µl = 190µl - // factor loss: ceil(19 × 10%) = ceil(1.9) = 2 positions × 10µl = 20µl - // min positions loss: 2 × 10µl = 20µl - // result: 190µl + max(20µl, 20µl) = 210µl - const column = pipettingLossTableColumn({ - count: 19, - pipettingLoss: { - type: 'factorWithMinimum', - factor: 0.1, - minPositions: 2, - }, - }); - render(<>{column.title}); - render( - <> - {column.render( - null, - { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, - 1, - )} - , - ); + expect(screen.getByText('30.0 µl')).toBeInTheDocument(); + expect(screen.getByText('2x Ansätze + 10% (PV)')).toBeInTheDocument(); + }); - expect(screen.getByText('210.0 µl')).toBeInTheDocument(); - expect(screen.getByText('19x Ansätze + 2x (PV)')).toBeInTheDocument(); + it('uses the minimum positions when the factor loss stays below them', () => { + // 19 ansätze × 10µl = 190µl + // factor loss: ceil(19 × 10%) = ceil(1.9) = 2 positions × 10µl = 20µl + // min positions loss: 2 × 10µl = 20µl + // result: 190µl + max(20µl, 20µl) = 210µl + const column = pipettingLossTableColumn({ + count: 19, + pipettingLoss: { + type: 'factorWithMinimum', + factor: 0.1, + minPositions: 2, + }, }); + render(<>{column.title}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); - it('uses the factor loss when it exceeds the minimum positions', () => { - // 21 ansätze × 10µl = 210µl - // factor loss: ceil(21 × 10%) = ceil(2.1) = 3 positions × 10µl = 30µl - // min positions loss: 2 × 10µl = 20µl - // result: 210µl + max(30µl, 20µl) = 240µl - const column = pipettingLossTableColumn({ - count: 21, - pipettingLoss: { - type: 'factorWithMinimum', - factor: 0.1, - minPositions: 2, - }, - }); - render(<>{column.title}); - render( - <> - {column.render( - null, - { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, - 1, - )} - , - ); + expect(screen.getByText('210.0 µl')).toBeInTheDocument(); + expect(screen.getByText('19x Ansätze + 2x (PV)')).toBeInTheDocument(); + }); - expect(screen.getByText('240.0 µl')).toBeInTheDocument(); - expect(screen.getByText('21x Ansätze + 10% (PV)')).toBeInTheDocument(); + it('uses the factor loss when it exceeds the minimum positions', () => { + // 21 ansätze × 10µl = 210µl + // factor loss: ceil(21 × 10%) = ceil(2.1) = 3 positions × 10µl = 30µl + // min positions loss: 2 × 10µl = 20µl + // result: 210µl + max(30µl, 20µl) = 240µl + const column = pipettingLossTableColumn({ + count: 21, + pipettingLoss: { + type: 'factorWithMinimum', + factor: 0.1, + minPositions: 2, + }, }); + render(<>{column.title}); + render( + <> + {column.render( + null, + { volume: 10, title: '', key: '1', rowKind: 'masterMixIngredient' }, + 1, + )} + , + ); + + expect(screen.getByText('240.0 µl')).toBeInTheDocument(); + expect(screen.getByText('21x Ansätze + 10% (PV)')).toBeInTheDocument(); }); }); From 371cde612269a18261a122eb655189e5e2836938 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 29/33] docs(MasterMix): keep only what the reaction volume is measured against --- src/MasterMix/reactionVolume.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/MasterMix/reactionVolume.ts b/src/MasterMix/reactionVolume.ts index bc6f61c7..5ebdfe7a 100644 --- a/src/MasterMix/reactionVolume.ts +++ b/src/MasterMix/reactionVolume.ts @@ -6,10 +6,7 @@ export function sumVolume(ingredients: Array): number { return sumBy(ingredients, (ingredient) => ingredient.volume); } -/** - * Volume of a single reaction: the master mix plus everything added per reaction. - * Concentrations of the ingredients are relative to this volume. - */ +/** Concentrations of the ingredients are relative to this volume. */ export function reactionVolume(mix: ReactionMix): number { return sumVolume([...mix.ingredients, ...(mix.perReactionIngredients ?? [])]); } From 350a9fca49b40e2108cbed6e3636910c82909557 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 30/33] docs(MasterMix): document the indentation rule where the levels are decided --- src/MasterMix/MasterMixRowName.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MasterMix/MasterMixRowName.tsx b/src/MasterMix/MasterMixRowName.tsx index 0a98e1fc..abb79887 100644 --- a/src/MasterMix/MasterMixRowName.tsx +++ b/src/MasterMix/MasterMixRowName.tsx @@ -6,9 +6,9 @@ const INDENT_STEP_IN_PIXELS = 20; const MARK_GAP_IN_PIXELS = 5; /** - * Indents by one step per sum the row contributes to, and holds the mark right before the - * name so that checking one off neither shifts the layout nor detaches from its row. - * The offset separates the mark from the name without moving the name on any level. + * Holds the mark right before the name, so that checking one off neither shifts the layout + * nor detaches from its row. The offset separates the mark from the name without moving + * the name on any level. */ const Indent = styled.span<{ $level: number }>` display: inline-block; From fc34e9f2c277e23fc4badbb5104e6f9346cdbdc2 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 31/33] docs(MasterMix): let the predicate name speak for itself --- src/MasterMix/hasPerReactionIngredients.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MasterMix/hasPerReactionIngredients.ts b/src/MasterMix/hasPerReactionIngredients.ts index f8076c4e..aad55307 100644 --- a/src/MasterMix/hasPerReactionIngredients.ts +++ b/src/MasterMix/hasPerReactionIngredients.ts @@ -1,6 +1,5 @@ import { MasterMixIngredient, ReactionMix } from './types'; -/** Without them, the reaction mix consists of nothing but the master mix. */ export function hasPerReactionIngredients( perReactionIngredients: ReactionMix['perReactionIngredients'], ): perReactionIngredients is Array { From cddb5f6dcfc7ced1ba4d520e754064710b1082ca Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 30 Jul 2026 21:05:16 +0200 Subject: [PATCH 32/33] docs(MasterMix): state the row order as a rule instead of a listing --- src/MasterMix/mixRows.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/MasterMix/mixRows.ts b/src/MasterMix/mixRows.ts index 4b39fe14..4e70a257 100644 --- a/src/MasterMix/mixRows.ts +++ b/src/MasterMix/mixRows.ts @@ -16,10 +16,7 @@ function ingredientRowKey( return `${rowKind}-${ingredient.key}`; } -/** - * Lists what to pipette, from the inside out: the ingredients of the master mix, - * their total as the amount that goes into each reaction, and what is added per reaction. - */ +/** Ordered from the inside out, so that every total follows what it sums up. */ export function mixRows({ ingredients, perReactionIngredients, From ab8c26701883919d9ff94d9cb762aefdeff07599 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 3 Aug 2026 08:27:32 +0200 Subject: [PATCH 33/33] Update src/Table/index.tsx Co-authored-by: mic-web <4804412+mic-web@users.noreply.github.com> --- src/Table/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Table/index.tsx b/src/Table/index.tsx index 2d38cd20..58ff40c9 100644 --- a/src/Table/index.tsx +++ b/src/Table/index.tsx @@ -52,7 +52,6 @@ export function Table< ((record, index) => { const rowProps = onRow(record, index); - /** Only rows that actually react to a click show that they can be clicked. */ return { ...rowProps, style: {