diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index c545258e..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('highlights the clicked ingredient but not the sum', () => { + 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( + , + ); + + 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('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 mark per reaction ingredients as pipetted', () => { + render( + , + ); + + fireEvent.click(screen.getByText('cDNA')); + + 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/MasterMixRowName.tsx b/src/MasterMix/MasterMixRowName.tsx new file mode 100644 index 00000000..abb79887 --- /dev/null +++ b/src/MasterMix/MasterMixRowName.tsx @@ -0,0 +1,39 @@ +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; + +const PIPETTED_MARK = '✓'; +const INDENT_STEP_IN_PIXELS = 20; +const MARK_GAP_IN_PIXELS = 5; + +/** + * 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}; +`; + +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 new file mode 100644 index 00000000..fb0e7225 --- /dev/null +++ b/src/MasterMix/MixTable.tsx @@ -0,0 +1,66 @@ +import { toggleElement } from '@mll-lab/js-utils'; +import React, { useState } from 'react'; + +import { + PIPETTED_ROW_CLASS, + TOTAL_VOLUME_ROW_CLASS, + VolumeTable, +} from './VolumeTable'; +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; +import { mixRows } from './mixRows'; +import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types'; +import { volumeColumns } from './volumeColumns'; + +type MixTableProps = ReactionMix & { + scaling?: PipettingScaling; +}; + +function rowClassName( + record: MasterMixTableRow, + pipettedKeys: Array, +): string { + switch (record.rowKind) { + case 'masterMixIngredient': + return pipettedKeys.includes(record.key) ? PIPETTED_ROW_CLASS : ''; + case 'masterMixTotal': + case 'reactionTotal': + return TOTAL_VOLUME_ROW_CLASS; + case 'perReactionIngredient': + return ''; + } +} + +/** Master mix ingredients can be clicked to mark them as pipetted. */ +export function MixTable({ + ingredients, + perReactionIngredients, + scaling, +}: MixTableProps) { + const [pipettedKeys, setPipettedKeys] = useState>([]); + + const withinReactionMix = hasPerReactionIngredients(perReactionIngredients); + + return ( + + rowClassName(record, pipettedKeys) + } + onRow={ + scaling && + ((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 new file mode 100644 index 00000000..0455c462 --- /dev/null +++ b/src/MasterMix/VolumeTable.tsx @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +import { Table } from '../Table'; +import { PALETTE } from '../theme'; + +export const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; +export const PIPETTED_ROW_CLASS = 'pipetted-row'; +export const REFERENCE_VOLUME_CLASS = 'reference-volume'; + +export const VolumeTable = styled(Table)` + .${TOTAL_VOLUME_ROW_CLASS} { + background-color: ${PALETTE.gray3}; + font-weight: bold; + } + + /* 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}; + } + + .${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}; + } +`; diff --git a/src/MasterMix/hasPerReactionIngredients.ts b/src/MasterMix/hasPerReactionIngredients.ts new file mode 100644 index 00000000..aad55307 --- /dev/null +++ b/src/MasterMix/hasPerReactionIngredients.ts @@ -0,0 +1,7 @@ +import { MasterMixIngredient, ReactionMix } from './types'; + +export function hasPerReactionIngredients( + perReactionIngredients: ReactionMix['perReactionIngredients'], +): perReactionIngredients is Array { + return Boolean(perReactionIngredients?.length); +} 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/index.stories.tsx b/src/MasterMix/index.stories.tsx index ea412370..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'; @@ -45,16 +45,26 @@ export default { }, }; +const PER_REACTION_INGREDIENTS: Array = [ + { key: 5, title: 'cDNA', volume: 5 }, +]; + +type StoryProps = { + name: string; + count: number; + ingredients: Array; + perReactionIngredients?: Array; + 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 +82,20 @@ export function Default({ return ; } + +export function WithPerReactionIngredients(props: StoryProps): ReactElement { + return ( + + ); +} + +export function Recipe({ name, ingredients }: StoryProps): ReactElement { + return ( + + ); +} diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index fe1ca443..e7391be3 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -1,95 +1,65 @@ -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 { MixTable } from './MixTable'; +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; +import { MASTER_MIX_LABEL } from './mixRows'; import { MasterMixProps } from './types'; +export { reactionVolume } from './reactionVolume'; export { MasterMixProps, MasterMixIngredient, + ReactionMix, PipettingLoss, PipettingLossAbsolute, PipettingLossByFactor, PipettingLossFactorWithMinimum, } from './types'; -const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row'; - -const MasterMixTable = styled(Table)` - .${TOTAL_VOLUME_ROW_CLASS} { - background-color: lightgrey; - } +/** + * 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; `; /** - * The reactants can be clicked and marked as pipetted. + * 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(props: MasterMixProps) { - const [highlightedEntries, setHighlightedEntries] = useState>( - [], - ); - - const ingredientsWithSumRow = [ - ...props.ingredients, - { - key: 'Total Volume (non-numeric string, guaranteed to be unique since ingredients keys must be of type number)', - title:

Gesamtvolumen

, - volume: props.ingredients.reduce( - (volumeAccumulator, ingredient) => - volumeAccumulator + ingredient.volume, - 0, - ), - }, - ]; +export function MasterMix({ + name, + count, + pipettingLoss, + ingredients, + perReactionIngredients, +}: MasterMixProps) { + /** Keyed on the scaling data itself, so a caller without types degrades to the recipe. */ + const scaling = pipettingLoss != null ? { count, pipettingLoss } : undefined; return ( {props.name} MasterMix + + {name}{' '} + {hasPerReactionIngredients(perReactionIngredients) + ? 'Reaktionsmix' + : MASTER_MIX_LABEL} + } > - { - if (index === props.ingredients.length) { - return TOTAL_VOLUME_ROW_CLASS; - } - - return highlightedEntries.includes(record.key.toString()) - ? 'mll-ant-table-row-selected' - : ''; - }} - dataSource={ingredientsWithSumRow} - rowKey={(record) => record.key} - pagination={{ defaultPageSize: 10, hideOnSinglePage: true }} - onRow={(record, index) => ({ - onClick: () => { - if (index === props.ingredients.length) { - // last row with the sum should not be clickable - return; - } - setHighlightedEntries((prevIDs) => - toggleElement(prevIDs, record.key.toString()), - ); - }, - })} - columns={[ - { - title: 'Name', - render: (_, record) => record.title, - }, - { - title: '1x', - render: (_, record) => <>{record.volume.toFixed(1)} µl, - }, - pipettingLossTableColumn(props), - ]} - /> + + + ); } 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/mixRows.ts b/src/MasterMix/mixRows.ts new file mode 100644 index 00000000..4e70a257 --- /dev/null +++ b/src/MasterMix/mixRows.ts @@ -0,0 +1,63 @@ +import { hasPerReactionIngredients } from './hasPerReactionIngredients'; +import { reactionVolume, sumVolume } from './reactionVolume'; +import { MasterMixIngredient, MasterMixTableRow, ReactionMix } from './types'; + +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. The keys of the + * total rows below carry no separator and can therefore never collide with these. + */ +function ingredientRowKey( + rowKind: 'masterMixIngredient' | 'perReactionIngredient', + ingredient: MasterMixIngredient, +): string { + return `${rowKind}-${ingredient.key}`; +} + +/** Ordered from the inside out, so that every total follows what it sums up. */ +export function mixRows({ + ingredients, + perReactionIngredients, +}: ReactionMix): Array { + 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 masterMixRows; + } + + return [ + ...masterMixRows, + ...perReactionIngredients.map( + (ingredient): MasterMixTableRow => ({ + ...ingredient, + key: ingredientRowKey('perReactionIngredient', ingredient), + rowKind: 'perReactionIngredient', + }), + ), + { + key: 'reactionTotal', + title: 'Reaktionsvolumen', + volume: reactionVolume({ ingredients, perReactionIngredients }), + rowKind: 'reactionTotal', + }, + ]; +} diff --git a/src/MasterMix/pipettingLossTableColumn.test.tsx b/src/MasterMix/pipettingLossTableColumn.test.tsx index eae67cd5..bace2a23 100644 --- a/src/MasterMix/pipettingLossTableColumn.test.tsx +++ b/src/MasterMix/pipettingLossTableColumn.test.tsx @@ -4,73 +4,99 @@ import React from 'react'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; describe('pipettingLossTableColumn', () => { - describe('with "absolute" pipetting loss type', () => { - it('should render the total volume and title correctly', () => { - const column = pipettingLossTableColumn({ - count: 2, - pipettingLoss: { type: 'absolute', count: 1 }, - }); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 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('should render the total volume and title correctly', () => { - const column = pipettingLossTableColumn({ - count: 2, - pipettingLoss: { type: 'factor', factor: 0.1 }, - }); - render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 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('should use minimum positions when factor loss is slightly below minimum positions', () => { - // 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 }, 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('should use factor loss when it is slightly above 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 }, 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(); }); }); diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index 3b4ee73d..a634a6c2 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -3,11 +3,11 @@ import React from 'react'; import { Tooltip } from '../Tooltip'; import { - IngredientWithStringOrNumberKey, + MasterMixTableRow, PipettingLoss, PipettingLossFactorWithMinimum, PipettingLossTableColumn, - PipettingLossTableColumnArgs, + PipettingScaling, } from './types'; type PipettingLosses = { @@ -52,10 +52,7 @@ function pipettingLossTitle( } } -function totalVolume( - record: IngredientWithStringOrNumberKey, - args: PipettingLossTableColumnArgs, -) { +function totalVolume(record: MasterMixTableRow, args: PipettingScaling) { switch (args.pipettingLoss.type) { case 'absolute': return (record.volume * (args.count + args.pipettingLoss.count)).toFixed( @@ -81,17 +78,26 @@ 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: IngredientWithStringOrNumberKey) => ( - <>{totalVolume(record, args)} µl - ), + render: (_: unknown, record: MasterMixTableRow) => { + switch (record.rowKind) { + /** Pipetted into each reaction individually, so the loss does not apply. */ + case 'perReactionIngredient': + case 'reactionTotal': + return <>–; + case 'masterMixIngredient': + case 'masterMixTotal': + return <>{totalVolume(record, args)} µl; + } + }, }; } 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..5ebdfe7a --- /dev/null +++ b/src/MasterMix/reactionVolume.ts @@ -0,0 +1,12 @@ +import { sumBy } from 'lodash'; + +import { MasterMixIngredient, ReactionMix } from './types'; + +export function sumVolume(ingredients: Array): number { + return sumBy(ingredients, (ingredient) => ingredient.volume); +} + +/** Concentrations of the ingredients are relative to this volume. */ +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 10ef9c9f..1a909a87 100644 --- a/src/MasterMix/types.ts +++ b/src/MasterMix/types.ts @@ -8,13 +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 = { @@ -27,24 +48,25 @@ export type PipettingLoss = | PipettingLossAbsolute | PipettingLossFactorWithMinimum; -export type IngredientWithStringOrNumberKey = Modify< - MasterMixIngredient, - { - key: string | number; - } ->; +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 PipettingLossTableColumn = Modify< - ColumnType, + ColumnType, { render: ( value: unknown, - record: IngredientWithStringOrNumberKey, + record: MasterMixTableRow, index: number, ) => React.ReactNode; } >; -export type PipettingLossTableColumnArgs = { - count: number; - pipettingLoss: PipettingLoss; -}; diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx new file mode 100644 index 00000000..31f49218 --- /dev/null +++ b/src/MasterMix/volumeColumns.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { MasterMixRowName } from './MasterMixRowName'; +import { REFERENCE_VOLUME_CLASS } from './VolumeTable'; +import { indentLevel } from './indentLevel'; +import { pipettingLossTableColumn } from './pipettingLossTableColumn'; +import { + MasterMixTableRow, + PipettingLossTableColumn, + PipettingScaling, +} from './types'; + +/** 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' || + record.rowKind === 'masterMixTotal' + ); +} + +export function volumeColumns( + scaling: PipettingScaling | undefined, + pipettedKeys: Array, + withinReactionMix: boolean, +): Array { + return [ + { + title: 'Name', + render: (_: unknown, record: MasterMixTableRow) => ( + + {record.title} + + ), + }, + { + title: scaling ? '1x' : 'Volumen', + align: 'right', + onCell: (record: MasterMixTableRow) => ({ + className: + scaling && belongsToMasterMix(record) + ? REFERENCE_VOLUME_CLASS + : undefined, + }), + render: (_: unknown, record: MasterMixTableRow) => ( + <>{record.volume.toFixed(1)} µl + ), + }, + ...(scaling ? [pipettingLossTableColumn(scaling)] : []), + ]; +} diff --git a/src/Table/index.tsx b/src/Table/index.tsx index fc356a9d..58ff40c9 100644 --- a/src/Table/index.tsx +++ b/src/Table/index.tsx @@ -36,27 +36,31 @@ 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); + + return { + ...rowProps, + style: { + cursor: rowProps.onClick ? 'pointer' : undefined, + ...rowProps.style, + }, + }; + }) + } loading={ typeof loading === 'object' ? {