Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c2b4980
feat(MasterMix): distinguish per reaction ingredients from the master…
simbig Jul 30, 2026
95a1c02
fix(MasterMix): show the master mix nested inside the reaction mix
simbig Jul 30, 2026
96fa81e
feat(MasterMix): rework into a single table with a nested master mix
simbig Jul 30, 2026
25d4d80
fix(MasterMix): namespace row keys by the kind of row
simbig Jul 30, 2026
f24af8e
refactor(MasterMix): let the compiler catch unscaled row kinds
simbig Jul 30, 2026
8bb4070
style(MasterMix): match the left edge of the box to the closing line
simbig Jul 30, 2026
5c2f517
refactor(MasterMix): stop exporting the pipetted mark
simbig Jul 30, 2026
65ffb48
style(MasterMix): leave the card width to the surrounding layout
simbig Jul 30, 2026
ce14bdf
style(MasterMix): indent the total volume with the ingredients it sums
simbig Jul 30, 2026
ecedf45
feat(MasterMix): name the master mix on the row that carries its volume
simbig Jul 30, 2026
e17b58c
style(MasterMix): outdent the totals from the rows they sum
simbig Jul 30, 2026
40fe450
style(MasterMix): indent every row one step below the total it feeds
simbig Jul 30, 2026
554ccb4
style(MasterMix): let the indentation carry the nesting alone
simbig Jul 30, 2026
f2639d7
style(MasterMix): set the pipetted mark off from the name
simbig Jul 30, 2026
306bcb1
fix(MasterMix): derive the scaling from the data instead of the mode
simbig Jul 30, 2026
b19370d
refactor(MasterMix): sum volumes with lodash
simbig Jul 30, 2026
84a992d
refactor(MasterMix): assemble the row classes with insertIf
simbig Jul 30, 2026
88e3062
refactor(MasterMix): inline names that are used once
simbig Jul 30, 2026
7588c7d
refactor(MasterMix): name the question whether anything is added per …
simbig Jul 30, 2026
fbeeb38
refactor(MasterMix): assemble the master mix rows once
simbig Jul 30, 2026
baf3c4f
refactor(MasterMix): keep the scaling mode types internal
simbig Jul 30, 2026
c95c091
refactor(Table): derive the pointer cursor per row
simbig Jul 30, 2026
bc27186
test(MasterMix): cover the row order and the indentation levels
simbig Jul 30, 2026
4299801
docs(MasterMix): explain the width where it is set
simbig Jul 30, 2026
5b69993
refactor(MasterMix): close a mix with the line of the total above it
simbig Jul 30, 2026
2d86197
refactor(MasterMix): address the row key by name
simbig Jul 30, 2026
99dad14
test(MasterMix): drop the row key assertion the unit test already covers
simbig Jul 30, 2026
49c871a
test(MasterMix): let the pipetting loss test names carry the loss type
simbig Jul 30, 2026
371cde6
docs(MasterMix): keep only what the reaction volume is measured against
simbig Jul 30, 2026
350a9fc
docs(MasterMix): document the indentation rule where the levels are d…
simbig Jul 30, 2026
fc34e9f
docs(MasterMix): let the predicate name speak for itself
simbig Jul 30, 2026
cddb5f6
docs(MasterMix): state the row order as a rule instead of a listing
simbig Jul 30, 2026
ab8c267
Update src/Table/index.tsx
simbig Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 99 additions & 24 deletions src/MasterMix/MasterMix.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MasterMix
name="Test"
Expand All @@ -39,21 +39,60 @@ describe('MasterMix', () => {
/>,
);

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(
<MasterMix
name={name}
count={count}
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
pipettingLoss={{ type: 'absolute', count: 2 }}
/>,
);

expect(screen.getByText(`${name} Reaktionsmix`)).toBeInTheDocument();
expect(screen.getByText('MasterMix')).toBeInTheDocument();
});

it('excludes per reaction ingredients from the master mix', () => {
render(
<MasterMix
name="Test"
count={count}
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
pipettingLoss={{ type: 'absolute', count: 2 }}
/>,
);

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(
<MasterMix
name="Test"
count={count}
ingredients={ingredients}
pipettingLoss={{ type: 'absolute', count: 2 }}
/>,
);

expect(screen.queryByText('Reaktionsvolumen')).not.toBeInTheDocument();
});

it('marks the clicked ingredient as pipetted but not the sum', () => {
render(
<MasterMix
name="Test"
Expand All @@ -63,24 +102,60 @@ describe('MasterMix', () => {
/>,
);

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(
<MasterMix
name="Test"
count={1}
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
pipettingLoss={{ type: 'absolute', count: 2 }}
/>,
);

fireEvent.click(screen.getByText('cDNA'));

expect(screen.queryAllByTitle('pipettiert')).toHaveLength(0);
});

it('shows only the volumes of a single reaction in recipe mode', () => {
render(
<MasterMix
name={name}
mode="recipe"
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
/>,
);

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(<MasterMix name="Test" mode="recipe" ingredients={ingredients} />);

fireEvent.click(screen.getByText('Water'));

expect(screen.queryAllByTitle('pipettiert')).toHaveLength(0);
});
});
39 changes: 39 additions & 0 deletions src/MasterMix/MasterMixRowName.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Indent $level={level} title={pipetted ? 'pipettiert' : undefined}>
{pipetted ? PIPETTED_MARK : null}
</Indent>
{children}
</>
);
}
66 changes: 66 additions & 0 deletions src/MasterMix/MixTable.tsx
Original file line number Diff line number Diff line change
@@ -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>,
): 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<Array<string>>([]);

const withinReactionMix = hasPerReactionIngredients(perReactionIngredients);

return (
<VolumeTable
dataSource={mixRows({ ingredients, perReactionIngredients })}
rowKey="key"
pagination={false}
rowClassName={(record: MasterMixTableRow) =>
rowClassName(record, pipettedKeys)
}
onRow={
scaling &&
((record: MasterMixTableRow) =>
record.rowKind === 'masterMixIngredient'
? {
onClick: () =>
setPipettedKeys((previouslyPipetted) =>
toggleElement(previouslyPipetted, record.key),
),
}
: {})
}
columns={volumeColumns(scaling, pipettedKeys, withinReactionMix)}
/>
);
}
29 changes: 29 additions & 0 deletions src/MasterMix/VolumeTable.tsx
Original file line number Diff line number Diff line change
@@ -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};
}
`;
7 changes: 7 additions & 0 deletions src/MasterMix/hasPerReactionIngredients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { MasterMixIngredient, ReactionMix } from './types';

export function hasPerReactionIngredients(
perReactionIngredients: ReactionMix['perReactionIngredients'],
): perReactionIngredients is Array<MasterMixIngredient> {
return Boolean(perReactionIngredients?.length);
}
48 changes: 48 additions & 0 deletions src/MasterMix/indentLevel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions src/MasterMix/indentLevel.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading