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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/MasterMix/MasterMix.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';

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

import { MasterMixIngredient } from './types';

import { MasterMix } from './index';
Expand Down Expand Up @@ -47,6 +49,26 @@ describe('MasterMix', () => {
expect(screen.getByText('901.8 µl')).toBeInTheDocument();
});

it('keeps each volume together with its unit', () => {
const keepWhitespace = (text: string) => text;

render(
<MasterMix
name="Test"
count={count}
ingredients={ingredients}
pipettingLoss={{ type: 'absolute', count: 2 }}
/>,
);

expect(
screen.getByText('79.5\u00A0µl', { normalizer: keepWhitespace }),
).toBeInTheDocument();
expect(
screen.getByText('901.8\u00A0µl', { normalizer: keepWhitespace }),
).toBeInTheDocument();
});

it('renders as reaction mix containing the master mix', () => {
render(
<MasterMix
Expand Down Expand Up @@ -158,4 +180,72 @@ describe('MasterMix', () => {

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

/* eslint-disable testing-library/no-container, testing-library/no-node-access -- antd wrappers have no role */
it('applies the given style', () => {
const { container } = render(
<Provider>
<MasterMix
name="Test"
mode="recipe"
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
bodyPadding="12px"
headerSpacing="4px"
indentStepInPixels={12}
/>
</Provider>,
);

expect(container.querySelector('.mll-ant-card-body')).toHaveStyle({
padding: '12px',
});
expect(container.querySelector('.mll-ant-card-head-title')).toHaveStyle({
paddingBlock: '4px',
});
expect(screen.getByText('Water').firstElementChild).toHaveStyle({
width: '24px',
});
});

it('adds pixels to a numeric header spacing', () => {
const { container } = render(
<Provider>
<MasterMix
name="Test"
mode="recipe"
ingredients={ingredients}
headerSpacing={4}
/>
</Provider>,
);

expect(container.querySelector('.mll-ant-card-head-title')).toHaveStyle({
paddingBlock: '4px',
});
});

it('keeps the default style without style props', () => {
const { container } = render(
<Provider>
<MasterMix
name="Test"
mode="recipe"
ingredients={ingredients}
perReactionIngredients={[{ key: 5, title: 'cDNA', volume: 5 }]}
/>
</Provider>,
);

expect(container.querySelector('.mll-ant-card-body')).not.toHaveAttribute(
'style',
);
expect(container.querySelector('.mll-ant-card-head-title')).not.toHaveStyle(
{ paddingBlock: '4px' },
);
expect(screen.getByText('Water').firstElementChild).toHaveStyle({
width: '40px',
});
});
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
});
15 changes: 11 additions & 4 deletions src/MasterMix/MasterMixRowName.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,43 @@
import React, { ReactNode } from 'react';
import styled from 'styled-components';

import { MasterMixStyle } from './types';

const PIPETTED_MARK = '✓';
const INDENT_STEP_IN_PIXELS = 20;
const DEFAULT_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 }>`
const Indent = styled.span<{ $widthInPixels: number }>`
display: inline-block;
position: relative;
right: ${MARK_GAP_IN_PIXELS}px;
width: ${(props) => props.$level * INDENT_STEP_IN_PIXELS}px;
width: ${(props) => props.$widthInPixels}px;
text-align: right;
color: ${(props) => props.theme.successColor};
`;

export function MasterMixRowName({
level,
pipetted,
indentStepInPixels = DEFAULT_INDENT_STEP_IN_PIXELS,
children,
}: {
level: number;
pipetted: boolean;
indentStepInPixels?: MasterMixStyle['indentStepInPixels'];
children: ReactNode;
}) {
return (
<>
<Indent $level={level} title={pipetted ? 'pipettiert' : undefined}>
<Indent
$widthInPixels={level * indentStepInPixels}
title={pipetted ? 'pipettiert' : undefined}
>
{pipetted ? PIPETTED_MARK : null}
</Indent>
{children}
Expand Down
22 changes: 17 additions & 5 deletions src/MasterMix/MixTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import {
} from './VolumeTable';
import { hasPerReactionIngredients } from './hasPerReactionIngredients';
import { mixRows } from './mixRows';
import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types';
import {
MasterMixStyle,
MasterMixTableRow,
PipettingScaling,
ReactionMix,
} from './types';
import { volumeColumns } from './volumeColumns';

type MixTableProps = ReactionMix & {
scaling?: PipettingScaling;
};
type MixTableProps = ReactionMix &
Pick<MasterMixStyle, 'indentStepInPixels'> & {
scaling?: PipettingScaling;
};

function rowClassName(
record: MasterMixTableRow,
Expand All @@ -35,6 +41,7 @@ export function MixTable({
ingredients,
perReactionIngredients,
scaling,
indentStepInPixels,
}: MixTableProps) {
const [pipettedKeys, setPipettedKeys] = useState<Array<string>>([]);

Expand All @@ -60,7 +67,12 @@ export function MixTable({
}
: {})
}
columns={volumeColumns(scaling, pipettedKeys, withinReactionMix)}
columns={volumeColumns({
scaling,
pipettedKeys,
withinReactionMix,
indentStepInPixels,
})}
/>
);
}
17 changes: 17 additions & 0 deletions src/MasterMix/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,20 @@ export function Recipe({ name, ingredients }: StoryProps): ReactElement {
/>
);
}

export function NarrowCard(props: StoryProps): ReactElement {
return (
<div style={{ width: '400px' }}>
<MasterMix
name={props.name}
count={props.count}
ingredients={props.ingredients}
perReactionIngredients={PER_REACTION_INGREDIENTS}
pipettingLoss={{ type: 'factor', factor: props.lossValue }}
bodyPadding="12px"
headerSpacing="4px"
indentStepInPixels={12}
/>
</div>
);
}
37 changes: 32 additions & 5 deletions src/MasterMix/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import React from 'react';
import styled from 'styled-components';
import styled, { css } from 'styled-components';

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';
import { MasterMixProps, MasterMixStyle } from './types';

export { reactionVolume } from './reactionVolume';
export {
MasterMixProps,
MasterMixStyle,
MasterMixIngredient,
ReactionMix,
PipettingLoss,
Expand All @@ -25,7 +26,27 @@ export {
* `max-width` on `.mll-ant-table-wrapper`.
*/
const MixContent = styled.div`
max-width: 400px;
max-width: 480px;
`;

const MixCard = styled(Card)<{
$headerSpacing: MasterMixStyle['headerSpacing'];
}>`
${(props) =>
props.$headerSpacing != null &&
css`
.mll-ant-card-head {
min-height: 0;
}
.mll-ant-card-head-title {
padding-block: ${typeof props.$headerSpacing === 'number'
? `${props.$headerSpacing}px`
: props.$headerSpacing};
}
.mll-ant-card-head-title .mll-ant-typography {
margin-bottom: 0;
}
`}
`;

/**
Expand All @@ -38,12 +59,17 @@ export function MasterMix({
pipettingLoss,
ingredients,
perReactionIngredients,
bodyPadding,
headerSpacing,
indentStepInPixels,
}: MasterMixProps) {
/** Keyed on the scaling data itself, so a caller without types degrades to the recipe. */
const scaling = pipettingLoss != null ? { count, pipettingLoss } : undefined;

return (
<Card
<MixCard
$headerSpacing={headerSpacing}
bodyStyle={bodyPadding != null ? { padding: bodyPadding } : undefined}
title={
<Typography.Title level={5}>
{name}{' '}
Expand All @@ -58,8 +84,9 @@ export function MasterMix({
ingredients={ingredients}
perReactionIngredients={perReactionIngredients}
scaling={scaling}
indentStepInPixels={indentStepInPixels}
/>
</MixContent>
</Card>
</MixCard>
);
}
2 changes: 1 addition & 1 deletion src/MasterMix/pipettingLossTableColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function pipettingLossTableColumn(
return <>–</>;
case 'masterMixIngredient':
case 'masterMixTotal':
return <>{totalVolume(record, args)} µl</>;
return <>{totalVolume(record, args)}&nbsp;µl</>;
}
},
};
Expand Down
9 changes: 8 additions & 1 deletion src/MasterMix/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Modify } from '@mll-lab/js-utils';
import { ColumnType } from 'antd/es/table';
import { ReactNode } from 'react';
import { CSSProperties, ReactNode } from 'react';

export type MasterMixIngredient = {
key: number;
Expand Down Expand Up @@ -31,9 +31,16 @@ export type RecipeMode = {
pipettingLoss?: never;
};

export type MasterMixStyle = {
bodyPadding?: CSSProperties['padding'];
headerSpacing?: CSSProperties['paddingBlock'];
indentStepInPixels?: number;
};

export type MasterMixProps = {
name: string;
} & ReactionMix &
MasterMixStyle &
(PipettingScaling | RecipeMode);

export type PipettingLossAbsolute = { type: 'absolute'; count: number };
Expand Down
20 changes: 14 additions & 6 deletions src/MasterMix/volumeColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { REFERENCE_VOLUME_CLASS } from './VolumeTable';
import { indentLevel } from './indentLevel';
import { pipettingLossTableColumn } from './pipettingLossTableColumn';
import {
MasterMixStyle,
MasterMixTableRow,
PipettingLossTableColumn,
PipettingScaling,
Expand All @@ -18,18 +19,25 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean {
);
}

export function volumeColumns(
scaling: PipettingScaling | undefined,
pipettedKeys: Array<string>,
withinReactionMix: boolean,
): Array<PipettingLossTableColumn> {
export function volumeColumns({
scaling,
pipettedKeys,
withinReactionMix,
indentStepInPixels,
}: {
scaling: PipettingScaling | undefined;
pipettedKeys: Array<string>;
withinReactionMix: boolean;
indentStepInPixels: MasterMixStyle['indentStepInPixels'];
}): Array<PipettingLossTableColumn> {
return [
{
title: 'Name',
render: (_: unknown, record: MasterMixTableRow) => (
<MasterMixRowName
level={indentLevel(record, withinReactionMix)}
pipetted={pipettedKeys.includes(record.key)}
indentStepInPixels={indentStepInPixels}
>
{record.title}
</MasterMixRowName>
Expand All @@ -45,7 +53,7 @@ export function volumeColumns(
: undefined,
}),
render: (_: unknown, record: MasterMixTableRow) => (
<>{record.volume.toFixed(1)} µl</>
<>{record.volume.toFixed(1)}&nbsp;µl</>
),
},
...(scaling ? [pipettingLossTableColumn(scaling)] : []),
Expand Down
Loading