diff --git a/cypress/e2e/pages/xeniumAnalyser.cy.ts b/cypress/e2e/pages/xeniumAnalyser.cy.ts index f45fb20b..6c393a54 100644 --- a/cypress/e2e/pages/xeniumAnalyser.cy.ts +++ b/cypress/e2e/pages/xeniumAnalyser.cy.ts @@ -86,8 +86,59 @@ describe('Xenium Analyser', () => { }); }); }); + + describe('Defining new regions of interest', () => { + before(() => { + cy.findByTestId('define-regions-button').click(); + }); + it('displays regions definer component', () => { + cy.findByText('Set Regions').should('be.visible'); + }); + describe('creating a new region', () => { + before(() => { + cy.findByTestId('region-color-0').click(); + cy.findByTestId('labware-region-definer-container').within(() => { + cy.findByText('A1').click(); + cy.findByText('B1').click({ cmdKey: true }); + }); + cy.findByTestId('create-update-region-button').click(); + cy.findByText('Done').click(); + }); + it('adds a region border with the same selected color on the the selected slots', () => { + cy.findByTestId('labware-STAN-3111').within(() => { + cy.get('div.border-black').should('have.length', 2); + }); + }); + it('updates the regions table accordingly', () => { + cy.findByTestId('STAN-3111-regions-table').get('tbody tr').should('have.length', 4); + }); + it('updates the region name accordingly', () => { + cy.findByText('SGP1009_Region1').should('be.visible'); + }); + }); + + describe('removing a region', () => { + before(() => { + cy.findByTestId('define-regions-button').click(); + cy.findByTestId('region-color-0').click(); + cy.findByTestId('remove-region-button').click(); + cy.findByText('Done').click(); + }); + it('removes the region border from the slots of that region', () => { + cy.findByTestId('labware-STAN-3111').within(() => { + cy.get('div.border-black').should('have.length', 0); + }); + }); + it('updates the regions table accordingly', () => { + cy.findByTestId('STAN-3111-regions-table').get('tbody tr').should('have.length', 5); + }); + it('renames the region of interest with the sample external id', () => { + cy.findByText('SGP1009_Region1').should('not.exist'); + }); + }); + }); }); - describe('When two labware is scanned ', () => { + describe('When two labware are scanned ', () => { before(() => { cy.get('#labwareScanInput').type('STAN-3112{enter}'); //scan second labware }); @@ -95,13 +146,13 @@ describe('Xenium Analyser', () => { cy.findAllByRole('table').eq(1).contains('STAN-3112'); cy.findByTestId('STAN-3112-workNumber').should('exist'); cy.findByTestId('STAN-3112-position').should('exist'); - cy.findByTestId('STAN-3112-samples').should('exist'); + cy.findByTestId('STAN-3112-regions-table').should('exist'); }); it('should disable any further labware scanning', () => { cy.get('#labwareScanInput').should('be.disabled'); }); }); - describe('When two labware is scanned and one is removed', () => { + describe('When two labware are scanned and one is removed', () => { before(() => { cy.findAllByTestId('removeButton').eq(1).click(); }); @@ -109,7 +160,7 @@ describe('Xenium Analyser', () => { cy.findByText('STAN-3112').should('not.exist'); cy.findByTestId('STAN-3112-workNumber').should('not.exist'); cy.findByTestId('STAN-3112-position').should('not.exist'); - cy.findByTestId('STAN-3112-samples').should('not.exist'); + cy.findByTestId('STAN-3112-regions-table').should('not.exist'); }); it('should enable labware scanning', () => { cy.get('#labwareScanInput').should('be.enabled'); diff --git a/src/components/WorkNumberSelect.tsx b/src/components/WorkNumberSelect.tsx index a2987612..c7509596 100644 --- a/src/components/WorkNumberSelect.tsx +++ b/src/components/WorkNumberSelect.tsx @@ -184,10 +184,10 @@ export default function WorkNumberSelect({ /> {!name && error.length ?

{error}

: ''}
- {currentSelectedWork && currentSelectedWork.project.length > 0 && ( + {currentSelectedWork && currentSelectedWork.project?.length > 0 && ( {currentSelectedWork.project} )} - {currentSelectedWork && currentSelectedWork.workRequester.length > 0 && ( + {currentSelectedWork && currentSelectedWork.workRequester?.length > 0 && ( {currentSelectedWork.workRequester} )}
diff --git a/src/components/labwarePerSection/Labware.tsx b/src/components/labwarePerSection/Labware.tsx new file mode 100644 index 00000000..31c4eb90 --- /dev/null +++ b/src/components/labwarePerSection/Labware.tsx @@ -0,0 +1,492 @@ +// This is a duplication of the original Labware.tsx component with +// modifications to support per-section labware operations. +// When a slot belonging to a section is clicked, the entire section is highlighted, +// and all operations are performed at the section level. +// Once the per-section labware feature is verified, stable, and integrated +// into the other operations, this component will replace the original Labware.tsx. + +import React, { useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; +import classNames from 'classnames'; +import { Slot } from '../labware/Slot'; +import { + buildAddresses, + GridDirection, + isSameArray, + LabwareDirection, + Position, + REGION_BORDER_COLORS, + SECTION_GROUPS_BG_COLORS +} from '../../lib/helpers'; +import _ from 'lodash'; +import { FlagPriority, LabwareFieldsFragment, LabwareFlaggedFieldsFragment, SlotFieldsFragment } from '../../types/sdk'; +import createLabwareMachine from './labware.machine'; +import { Selectable, SelectionMode } from './labware.types'; +import { NewFlaggedLabwareLayout, NewLabwareLayout } from '../../types/stan'; +import { useMachine } from '@xstate/react'; +import * as slotHelper from '../../lib/helpers/slotHelper'; +import SlotColumnInfo from '../labware/SlotColumnInfo'; +import { Link } from 'react-router-dom'; +import FlagIcon from '../icons/FlagIcon'; +import BarcodeIcon from '../icons/BarcodeIcon'; +import BubleChatIcon from '../icons/BubleChatIcon'; +import { PlannedSectionDetails } from '../../lib/machines/layout/layoutContext'; +import { sectionGroupsBySample } from '../../lib/helpers/labwareHelper'; +import { Region } from '../../pages/XeniumAnalyser'; + +export interface LabwareProps { + /** + * The labware to display. May be a new piece of labware not yet persisted on core. + */ + labware: LabwareFlaggedFieldsFragment | NewFlaggedLabwareLayout | NewLabwareLayout; + + /** + * (Optional) Name to be displayed on the labware + */ + name?: string; + + /** + * Callback for when the labware is clicked + */ + onClick?: () => void; + + /** + * What selection mode should the labware be in? This parameter is ignored if `selectable` is `none`. + * + * + */ + selectionMode?: SelectionMode; + + /** + * Which slots are allowed to be selected? + * + */ + selectable?: Selectable; + + /** + * Callback for when a slot is clicked + * @param address the address of the clicked slot + * @param slot the slot + */ + onSlotClick?: (address: string, slot: SlotFieldsFragment) => void; + + /** + * Callback for when a slot is clicked with Ctrl button held + * @param address the address of the clicked slot + * @param slot the slot + */ + onSlotCtrlClick?: (address: string, slot: SlotFieldsFragment) => void; + + /** + * Callback for when slots are selected + * @param selected the addresses of the selected slots + */ + onSelect?: (selected: Array) => void; + + /** + * Callback for when the mouse first hovers over a slot + * @param address the address of the slot + * @param slot the slot + */ + onSlotMouseEnter?: (address: string, slot: SlotFieldsFragment) => void; + + /** + * Callback for when the mouse moves off a slot + * @param address the address of the slot + * @param slot the slot + */ + onSlotMouseLeave?: (address: string, slot: SlotFieldsFragment) => void; + + /** + * Callback to customise the text for an individual slot + * @param address the address of the slot to customise text of + * @param slot the slot + */ + slotText?: (address: string, slot: SlotFieldsFragment) => string | undefined; + + /** + * Callback to customise the secondary text for an individual slot. Secondary text appears under slotText. + * @param address the address of the slot to customise secondary text of + * @param slot the slot + */ + slotSecondaryText?: (address: string, slot: SlotFieldsFragment) => string | undefined; + + /** + * Callback to customise the text for an individual slot + * @param address the address of the slot + * @param slot the slot + */ + slotColor?: (address: string, slot: SlotFieldsFragment) => string | undefined; + + labwareRef?: React.RefObject; + + /** + * A callback used to manage references to individual labware elements. + * This is useful for dynamically tracking labware elements in a collection. + */ + labwareRefCallback?: (el: LabwareImperativeRef) => void; + + /** + * A callback that will be called for each slot in the labware. Must return a react component that will be placed + * in the labelled slot beside the component + * @param slot a slot on the given labware + */ + slotBuilder?: (slot: SlotFieldsFragment) => React.ReactNode; + + /** + * Cleaned out addresses are addresses that have been cleaned out and should not be used for storing samples. + * Cleaned out addresses are displayed crossed over in the UI. + */ + cleanedOutAddresses?: string[]; + + barcodeInfoPosition?: Position; + + /** + * Specifies the grid direction used to determine the positioning of wells inside the labware. + */ + gridDirection?: GridDirection; + + /** + * Callback to highlight slots due to an external action + * without requiring direct selection or clicking on the slot. + * This is useful when existing actions are already hooked to + * the `onSelect` and `onClick` events. + * @param addresses The addresses of the slots to highlight. + */ + highlightedSlots?: Set; + + /** + * Specifies the orientation of the labware layout. Defaults to vertical. + */ + labwareDirection?: LabwareDirection; + + /** + * Optional mapping of section groups. + * Each key represents a section name or ID, and the value is an array of addresses belonging to that section. + */ + sectionGroups?: Record; + + regions?: Array; +} + +export type LabwareImperativeRef = { + deselectAll: () => void; +}; + +/** + * Component for displaying an individual piece of labware and its slots. + * + * Labware will contain a grid of slots, each of which can hold zero to many samples. The colour and text of each slot + * can be controlled with callbacks to `slotColor` and `slotText`. + * + * Selection of slots can be controlled with the `selectionMode` and `selectable` parameters. See the params for more details. + */ +const Labware = ({ + labware, + onClick, + onSlotClick, + onSlotCtrlClick, + selectionMode = 'single', + selectable = 'none', + name, + onSelect, + onSlotMouseEnter, + onSlotMouseLeave, + slotText, + slotSecondaryText, + slotColor, + labwareRef, + slotBuilder, + cleanedOutAddresses, + barcodeInfoPosition, + gridDirection, + highlightedSlots, + labwareDirection, + labwareRefCallback, + sectionGroups, + regions +}: React.PropsWithChildren) => { + const labwareMachine = React.useMemo(() => { + return createLabwareMachine(); + }, []); + const [current, send] = useMachine(labwareMachine, { + input: { + selectionMode, + selectable, + slots: labware.slots, + selectedAddresses: new Set(), + lastSelectedAddress: null, + sectionGroups: sectionGroups ?? sectionGroupsBySample(labware as LabwareFieldsFragment) + } + }); + const { selectedAddresses } = current.context; + + const selectedAddressesRef = React.useRef>(); + const { + labwareType: { numRows, numColumns }, + slots, + barcode + } = labware; + + const isFlagged = useMemo(() => { + return (labware as LabwareFlaggedFieldsFragment).flagged; + }, [labware]); + + useImperativeHandle(labwareRef, () => ({ + deselectAll: () => send({ type: 'RESET_SELECTED' }) + })); + useImperativeHandle(labwareRefCallback, () => ({ + deselectAll: () => send({ type: 'RESET_SELECTED' }) + })); + + useEffect(() => { + send({ + type: 'CHANGE_SELECTION_MODE', + selectionMode, + selectable + }); + }, [send, selectionMode, selectable]); + + useEffect(() => { + send({ type: 'UPDATE_SLOTS', slots: slots ?? [] }); + }, [send, slots]); + + /**When ever selected address changes, a callback is invoked**/ + useEffect(() => { + //Make sure that there is a change in selected addresses, if not don't call the callback function + if ( + selectedAddressesRef.current && + isSameArray(Array.from(selectedAddresses), Array.from(selectedAddressesRef.current)) + ) { + return; + } + selectedAddressesRef.current = selectedAddresses; + onSelect?.(Array.from(selectedAddresses)); + }, [onSelect, selectedAddresses]); + + const isBarcodeInfoAtTheTop = barcodeInfoPosition === Position.TopLeft || barcodeInfoPosition === Position.TopRight; + + const isBarcodeInfoAtTheBottom = + barcodeInfoPosition === Position.BottomRight || barcodeInfoPosition === Position.BottomLeft; + + const isBarcodeInfoAtTheLeft = + barcodeInfoPosition === Position.TopLeft || barcodeInfoPosition === Position.BottomLeft; + + const isBarcodeInfoAtTheLeftSide = barcodeInfoPosition === Position.Left; + + const isBarcodeInfoAtTheRightSide = barcodeInfoPosition === Position.Right; + + const labwareDisplayClass = + isBarcodeInfoAtTheLeftSide || isBarcodeInfoAtTheRightSide ? 'flex flex row' : 'inline-block'; + + const labwareClasses = `${labwareDisplayClass} border border-sdb py-2 rounded-lg bg-blue-100 transition duration-300 ease-in-out items-center`; + + const grid = + labwareDirection && labwareDirection === LabwareDirection.Horizontal + ? `grid grid-cols-${numRows} grid-rows-${numColumns}` + : `grid grid-rows-${numRows} grid-cols-${numColumns}`; + + const gridClasses = classNames( + { + 'px-12 gap-4 md:px-3 md:gap-1': numColumns <= 3, + 'px-10 gap-3 md:px-2 md:gap-1': numColumns > 3 && numColumns <= 5, + 'px-6 gap-2 md:px-1 md:gap-1': numColumns > 6 + }, + + `${grid} py-4 select-none md:py-1` + ); + + // Give slots some default styles if some haven't been passed in + const _slotColor = + slotColor ?? + ((address, slot) => { + if (slotHelper.hasMultipleSamples(slot)) { + return 'bg-linear-to-r from-purple-400 via-pink-500 to-red-500'; + } else if (slotHelper.isSlotFilled(slot)) { + return 'bg-sdb-300'; + } + }); + + const slotByAddress = _.keyBy(slots, 'address'); + + sectionGroups = React.useMemo(() => { + return sectionGroups ?? sectionGroupsBySample(labware as LabwareFieldsFragment); + }, [labware, sectionGroups]); + + const internalOnClick = React.useCallback( + (address: string, slot: SlotFieldsFragment) => { + onSlotClick?.(address, slot); + send({ type: 'SELECT_SLOT', address: address }); + }, + [onSlotClick, send] + ); + + const onCtrlClick = useCallback( + (address: string, slot: SlotFieldsFragment) => { + onSlotCtrlClick?.(address, slot); + send({ type: 'CTRL_SELECT_SLOT', address }); + }, + [send, onSlotCtrlClick] + ); + + const onShiftClick = useCallback( + (address: string) => { + send({ type: 'SELECT_TO_SLOT', address }); + }, + [send] + ); + + /*** + * This creates an array of slots with the same column layout as of the labware type + */ + const slotColumns = React.useMemo(() => { + if (numColumns > 2) return []; + const slotColumns: Array = new Array(numColumns); + slots.forEach((slot, index) => { + const colIndex = index % numColumns; + if (!slotColumns[colIndex]) slotColumns[colIndex] = new Array(); + slotColumns[index % numColumns].push(slot); + }); + return slotColumns; + }, [numColumns, slots]); + + const barcodePositionClassName = (): string => { + if (barcodeInfoPosition && isBarcodeInfoAtTheLeft) return 'items-end'; + return 'items-start'; + }; + + const BarcodeInformation = () => ( +
+ {name && {name}} + {barcode && !isFlagged && ( + + + {barcode} + + )} + {barcode && 'flagPriority' in labware ? ( + + + {labware.flagPriority === FlagPriority.Flag && ( + + )} + {labware.flagPriority === FlagPriority.Note && ( + + )} + + {barcode} + + + ) : ( + '' + )} +
+ ); + + const slotSectionBgColor = (): Record => { + const result: Record = {}; + if (!sectionGroups) return result; + Object.entries(sectionGroups) + .filter(([, sectionDetails]) => sectionDetails.addresses.size > 1) + .forEach(([groupId, sectionDetails]) => { + sectionDetails.addresses.forEach((address) => { + result[address] = SECTION_GROUPS_BG_COLORS[Number(groupId)]; + }); + }); + return result; + }; + + const slotSizeProps = useMemo(() => { + const count = LabwareDirection.Horizontal ? numRows : numColumns; + if (count > 6) return { size: 'size-16', parentDivSize: 'size-17', textSize: 'text-[10px]' }; + if (count > 3) return { size: 'size-18', parentDivSize: 'size-19', textSize: ' text-[11px]' }; + return { size: 'size-20', parentDivSize: 'size-21', textSize: 'text-xs' }; + }, [numColumns, numRows]); + + const slotRegionBorderColor = useMemo((): Record => { + const result: Record = {}; + if (!regions) return result; + regions.forEach((region) => { + if (region.sectionGroups.length <= 1) return; + region.sectionGroups.flatMap((sectionGroups) => + sectionGroups.addresses.forEach((address) => { + result[address] = REGION_BORDER_COLORS[region.colorIndexNumber!]; + }) + ); + }); + return result; + }, [regions]); + + const regionWrapperClass = (address: string) => + slotRegionBorderColor[address] ? `rounded-lg border-2 ${slotRegionBorderColor[address]}` : ''; + + return ( +
+ {slotColumns.length > 0 && slotBuilder && ( + + )} +
onClick?.()} className={labwareClasses}> + {(isBarcodeInfoAtTheLeftSide || isBarcodeInfoAtTheTop) && BarcodeInformation()} +
+ {buildAddresses({ numColumns, numRows }, gridDirection).map((address, i) => { + return ( +
+
+ +
+
+ ); + })} +
+ + {(!barcodeInfoPosition || isBarcodeInfoAtTheBottom || isBarcodeInfoAtTheRightSide) && BarcodeInformation()} +
+ + {slotColumns.length > 1 && slotBuilder && ( + + )} +
+ ); +}; + +export default Labware; diff --git a/src/components/labwarePerSection/labware.machine.ts b/src/components/labwarePerSection/labware.machine.ts new file mode 100644 index 00000000..b0743671 --- /dev/null +++ b/src/components/labwarePerSection/labware.machine.ts @@ -0,0 +1,354 @@ +// This is a duplication of the original labware.machine component with +// modifications to support per-section labware operations. +// When a slot belonging to a section is clicked, the entire section is highlighted, +// and all operations are performed at the section level. +// Once the per-section labware feature is verified, stable, and integrated +// into the other operations, this component will replace the original labware.machine. + +import { assign, createMachine, enqueueActions } from 'xstate'; +import { LabwareMachineContext, LabwareMachineEvent, LabwareMachineSchema } from './labware.types'; +import { emptySlots, filledSlots, findSlotByAddress, isSlotEmpty, isSlotFilled } from '../../lib/helpers/slotHelper'; +import { sortDownRight } from '../../lib/helpers/labwareHelper'; +import { SlotFieldsFragment } from '../../types/sdk'; +import { PlannedSectionDetails } from '../../lib/machines/layout/layoutContext'; + +function createLabwareMachine() { + return createMachine( + { + id: 'labwareMachine', + types: {} as { + context: LabwareMachineContext; + schema: LabwareMachineSchema; + events: LabwareMachineEvent; + }, + initial: 'unknown', + context: ({ input }: { input: LabwareMachineContext }) => ({ + slots: input.slots ?? [], + selectedAddresses: input.selectedAddresses ?? new Set(), + lastSelectedAddress: input.lastSelectedAddress ?? null, + selectionMode: input.selectionMode ?? 'single', + selectable: input.selectable ?? 'none', + sectionGroups: input.sectionGroups + }), + on: { + RESET_SELECTED: { + actions: 'clearSelectedSlots' + }, + CHANGE_SELECTION_MODE: { + actions: assign(({ context, event }) => { + return { + ...context, + selectable: event.selectable, + selectionMode: event.selectionMode, + selectedAddresses: new Set() + }; + }), + target: '.unknown' + }, + UPDATE_SLOTS: { + actions: 'updateSlots' + } + }, + states: { + unknown: { + always: chooseState + }, + non_selectable: {}, + selectable: { + initial: 'any', + states: { + any: { + initial: 'single', + states: { + single: { + on: { + SELECT_SLOT: singleSelectSlotHandler, + CTRL_SELECT_SLOT: singleCtrlSelectSlotHandler + } + }, + multi: { + on: { + SELECT_SLOT: multiSelectSlotHandler, + SELECT_TO_SLOT: multiSelectToSlotHandler, + CTRL_SELECT_SLOT: multiCtrlSelectSlotHandler + } + } + } + }, + non_empty: { + initial: 'single', + states: { + /** + * The event handlers in this state are the same as `{any: single}` above + * EXCEPT, they will check the selected well is non-empty first. If it's not, no action is taken. + */ + single: { + on: { + SELECT_SLOT: [isSelectedEmptyHandler, ...singleSelectSlotHandler], + CTRL_SELECT_SLOT: [isSelectedEmptyHandler, ...singleCtrlSelectSlotHandler] + } + }, + multi: { + /** + * The event handlers in this state are the same as `{any: multi}` above + * EXCEPT, they will check the selected well is non-empty first. If it's not, no action is taken. + */ + on: { + SELECT_SLOT: [isSelectedEmptyHandler, ...multiSelectSlotHandler], + SELECT_TO_SLOT: [isSelectedEmptyHandler, ...multiSelectToSlotHandler], + CTRL_SELECT_SLOT: [isSelectedEmptyHandler, ...multiCtrlSelectSlotHandler] + } + } + } + }, + empty: { + initial: 'single', + states: { + single: { + on: { + /** + * The event handlers in this state are exactly the same as `{any: single}` above + * EXCEPT, they will check the selected well is empty first. If it is, no action is taken. + */ + SELECT_SLOT: [isSelectedNotEmptyHandler, ...singleSelectSlotHandler], + CTRL_SELECT_SLOT: [isSelectedNotEmptyHandler, ...singleCtrlSelectSlotHandler] + } + }, + multi: { + /** + * The event handlers in this state are exactly the same as `{any: multi}` above + * EXCEPT, they will check the selected well is empty first. If it is, no action is taken. + */ + on: { + SELECT_SLOT: [isSelectedNotEmptyHandler, ...multiSelectSlotHandler], + SELECT_TO_SLOT: [isSelectedNotEmptyHandler, ...multiSelectToSlotHandler], + CTRL_SELECT_SLOT: [isSelectedNotEmptyHandler, ...multiCtrlSelectSlotHandler] + } + } + } + } + } + }, + locked: {} + } + }, + { + actions: { + clearSelectedSlots: assign(({ context }) => { + return { ...context, selectedAddresses: new Set() }; + }), + + deselectSlot: assign(({ context, event }) => { + if ('address' in event) { + const selectedSectionAddresses = sectionGroupAddresses(event.address, context.sectionGroups); + return { + ...context, + selectedAddresses: new Set([...context.selectedAddresses].filter((a) => !selectedSectionAddresses.has(a))) + }; + } + return context; + }), + + forwardEvent: enqueueActions(({ context, event, enqueue }) => { + enqueue(event); + }), + + selectSlot: assign(({ context, event }) => { + if ('address' in event) { + const selectedSectionAddresses = sectionGroupAddresses(event.address, context.sectionGroups); + const selectedAddresses = new Set(context.selectedAddresses); + selectedSectionAddresses.forEach((address) => selectedAddresses.add(address)); + return { + ...context, + selectedAddresses + }; + } + return context; + }), + + storeLastSelectedSlot: assign(({ context, event }) => { + if ('address' in event) { + return { + ...context, + lastSelectedAddress: event.address + }; + } + return context; + }), + + /** + * Selects all slots between the `ctx.lastSelectedAddress` (if available), and the newly clicked address. + * Takes into account the current state (e.g. empty, non-empty) of the machine + */ + selectSlotsBetween: assign(({ context, event, self }) => { + if (event.type !== 'SELECT_TO_SLOT' || context.lastSelectedAddress == null) { + return context; + } + // This may need to be configurable in the future...? + const sortedSlots = sortDownRight(context.slots) as SlotFieldsFragment[]; + + const selectedAddressIndex = sortedSlots.findIndex((slot) => slot.address === event.address); + const lastSelectedAddressIndex = sortedSlots.findIndex( + (slot) => slot.address === context.lastSelectedAddress + ); + + const [startSlotIndex, endSlotIndex] = [ + selectedAddressIndex, + lastSelectedAddressIndex + // The default JS sort is stupid, as it converts values to strings, and compares them lexicographically. + // e.g. you end up with nonsense like 9 > 80. + // To compare numbers, we must provide our own comparator. + ].sort((a, b) => a - b); + + let selectedSlots = sortedSlots.slice(startSlotIndex, endSlotIndex + 1); + const snapshot = self.getSnapshot(); + + // If we only want to select non-empty wells, filter out empty ones... + if (snapshot.matches({ selectable: { non_empty: 'multi' } })) { + selectedSlots = filledSlots(selectedSlots); + + // If we only want to select empty wells, filter out non-empty ones... + } else if (snapshot.matches({ selectable: { empty: 'multi' } })) { + selectedSlots = emptySlots(selectedSlots); + } + + const selectedAddresses = new Set(context.selectedAddresses); + selectedSlots.forEach((slot) => selectedAddresses.add(slot.address)); + return { ...context, selectedAddresses }; + }), + + updateSlots: assign(({ context, event }) => { + if (event.type === 'UPDATE_SLOTS') { + return { + ...context, + slots: event.slots + }; + } + return context; + }) + }, + + guards: { + isSelectedEmpty: ({ context, event }) => { + return 'address' in event && isSlotEmpty(findSlotByAddress(context.slots, event.address)); + }, + + isSelectedNotEmpty: ({ context, event }) => { + return 'address' in event && isSlotFilled(findSlotByAddress(context.slots, event.address)); + }, + + isSlotSelected: ({ context, event }) => { + return 'address' in event && context.selectedAddresses.has(event.address); + }, + + isLastSelectedSlot: ({ context, event }) => context.lastSelectedAddress != null + } + } + ); +} + +export default createLabwareMachine; + +/** + * Actions for choosing the next state based on the current context's `selectable` and `selectionMode` values + */ +const chooseState = [ + { + guard: ({ context }: { context: LabwareMachineContext }) => context.selectable === 'none', + target: 'non_selectable' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'any' && context.selectionMode === 'single', + target: 'selectable.any.single' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'any' && context.selectionMode === 'multi', + target: 'selectable.any.multi' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'non_empty' && context.selectionMode === 'single', + target: 'selectable.non_empty.single' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'non_empty' && context.selectionMode === 'multi', + target: 'selectable.non_empty.multi' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'empty' && context.selectionMode === 'single', + target: 'selectable.empty.single' + }, + { + guard: ({ context }: { context: LabwareMachineContext }) => + context.selectable === 'empty' && context.selectionMode === 'multi', + target: 'selectable.empty.multi' + } +]; + +/** + * Event handlers with conditions for checking if a selected slot is empty or not + */ +const isSelectedEmptyHandler = { + guard: 'isSelectedEmpty' +}; + +const isSelectedNotEmptyHandler = { + guard: 'isSelectedNotEmpty' +}; + +/** + * Handlers for the various states of the machine ({any, empty, non_empty} and {single, multi}) + */ +const singleSelectSlotHandler = [ + { + guard: 'isSlotSelected' + }, + { + actions: ['clearSelectedSlots', 'selectSlot'] + } +]; +const singleCtrlSelectSlotHandler = [ + { + guard: 'isSlotSelected', + actions: ['deselectSlot'] + }, + { + actions: ['clearSelectedSlots', 'selectSlot'] + } +]; +const multiSelectSlotHandler = [ + { + actions: ['clearSelectedSlots', 'selectSlot', 'storeLastSelectedSlot'] + } +]; +const multiSelectToSlotHandler = [ + { + guard: 'isSlotSelected', + actions: ['deselectSlot'] + }, + { + guard: 'isLastSelectedSlot', + actions: ['selectSlotsBetween'] + }, + { + actions: ['selectSlot', 'storeLastSelectedSlot'] + } +]; +const multiCtrlSelectSlotHandler = [ + { + guard: 'isSlotSelected', + actions: ['deselectSlot'] + }, + { + actions: ['selectSlot'] + } +]; + +const sectionGroupAddresses = (address: string, sectionGroups: Record) => { + const sectionGroup = Object.values(sectionGroups).find((group) => group.addresses.has(address)); + return sectionGroup ? sectionGroup.addresses : new Set([address]); +}; diff --git a/src/components/labwarePerSection/labware.types.ts b/src/components/labwarePerSection/labware.types.ts new file mode 100644 index 00000000..d85ab0f6 --- /dev/null +++ b/src/components/labwarePerSection/labware.types.ts @@ -0,0 +1,76 @@ +// This is a duplication of the original labware.types component with +// modifications to support per-section labware operations. +// When a slot belonging to a section is clicked, the entire section is highlighted, +// and all operations are performed at the section level. +// Once the per-section labware feature is verified, stable, and integrated +// into the other operations, this component will replace the original labware.types. + +import { Maybe, SlotFieldsFragment } from '../../types/sdk'; +import { PlannedSectionDetails } from '../../lib/machines/layout/layoutContext'; + +export type SelectionMode = 'single' | 'multi'; +export type Selectable = 'none' | 'any' | 'non_empty' | 'empty'; + +export interface LabwareMachineContext { + slots: Array; + selectedAddresses: Set; + lastSelectedAddress: Maybe; + selectionMode: SelectionMode; + selectable: Selectable; + sectionGroups: Record; +} + +export interface LabwareMachineSchema { + states: { + unknown: {}; + non_selectable: {}; + selectable: { + states: { + any: { + states: { + single: {}; + multi: {}; + }; + }; + non_empty: { + states: { + single: {}; + multi: {}; + }; + }; + empty: { + states: { + single: {}; + multi: {}; + }; + }; + }; + }; + locked: {}; + }; +} + +type SelectSlotEvent = { type: 'SELECT_SLOT'; address: string }; +type CtrlSelectSlotEvent = { type: 'CTRL_SELECT_SLOT'; address: string }; +type SelectToSlotEvent = { type: 'SELECT_TO_SLOT'; address: string }; + +export type ChangeSelectionModeEvent = { + type: 'CHANGE_SELECTION_MODE'; + selectionMode: SelectionMode; + selectable: Selectable; +}; + +export type UpdateSlotsEvent = { + type: 'UPDATE_SLOTS'; + slots: Array; +}; + +type ResetSelected = { type: 'RESET_SELECTED' }; + +export type LabwareMachineEvent = + | SelectSlotEvent + | CtrlSelectSlotEvent + | SelectToSlotEvent + | ChangeSelectionModeEvent + | UpdateSlotsEvent + | ResetSelected; diff --git a/src/components/xeniumAnalyser/RegionDefiner.tsx b/src/components/xeniumAnalyser/RegionDefiner.tsx new file mode 100644 index 00000000..c68137f1 --- /dev/null +++ b/src/components/xeniumAnalyser/RegionDefiner.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useRef } from 'react'; +import Labware from '../labwarePerSection/Labware'; +import { FormikErrors, useFormikContext } from 'formik'; +import { GridDirection, REGION_BORDER_COLORS } from '../../lib/helpers'; +import Heading from '../Heading'; +import { + AnalyserLabwareForm, + Region, + reIndexAndRenameRegions, + XeniumAnalyserFormValues +} from '../../pages/XeniumAnalyser'; +import { LabwareImperativeRef } from '../labware/Labware'; +import warningToast from '../notifications/WarningToast'; +import { toast } from 'react-toastify'; +import { PlannedSectionDetails } from '../../lib/machines/layout/layoutContext'; + +type RegionDefinerProps = { + labwareIndex: number; +}; + +function isLabwareErrorObject(err: unknown): err is FormikErrors { + return typeof err === 'object' && err !== null; +} + +const RegionDefiner = ({ labwareIndex }: RegionDefinerProps) => { + const { values, errors, setFieldValue, setFieldError, setValues } = useFormikContext(); + + const analyserLabware = values.labware[labwareIndex]; + + const labwareRef = useRef(); + const deselectLabwareSlots = React.useCallback(() => { + labwareRef.current?.deselectAll(); + }, [labwareRef]); + + const handleWarning = useCallback( + (warningMessage: string, fieldName: string) => { + warningToast({ + message: warningMessage, + position: toast.POSITION.TOP_RIGHT, + autoClose: 7000 + }); + deselectLabwareSlots(); + setFieldError(fieldName, undefined); + }, + [deselectLabwareSlots, setFieldError] + ); + + React.useEffect(() => { + const labwareError = errors.labware?.[labwareIndex]; + + if (!isLabwareErrorObject(labwareError)) return; + + if (labwareError.selectedRegionColorIndex) { + handleWarning(labwareError.selectedRegionColorIndex, `labware[${labwareIndex}].selectedRegionColorIndex`); + } + + if (labwareError.selectedAddresses) { + handleWarning(labwareError.selectedAddresses, `labware[${labwareIndex}].selectedAddresses`); + } + }, [errors.labware, handleWarning, labwareIndex]); + + const setRegion = async (runName: string, sgpNumber: string) => { + // ---------------------------------- + // Validate inputs + // ---------------------------------- + const selectedRegionColorIndex = analyserLabware.selectedRegionColorIndex; + + if (selectedRegionColorIndex === undefined) { + setFieldError( + `labware[${labwareIndex}].selectedRegionColorIndex`, + 'Please select a region color before creating a region.' + ); + return; + } + + if (!analyserLabware.selectedAddresses || analyserLabware.selectedAddresses.size === 0) { + setFieldError( + `labware[${labwareIndex}].selectedAddresses`, + 'Please select the slots or sections before creating a region.' + ); + return; + } + + // Set of addresses selected by the user + const selectedAddresses = new Set(analyserLabware.selectedAddresses); + + // ---------------------------------- + // Working collections + // ---------------------------------- + + // Stores all regions that will remain after extraction + const regions: Region[] = []; + + // Stores all sections that will form the new region + const newRegionSections: PlannedSectionDetails[] = []; + // ---------------------------------- + // Extract selected sections + // ---------------------------------- + analyserLabware.regions.forEach((region) => { + // Sections that stay in this region + const remaining: PlannedSectionDetails[] = []; + + region.sectionGroups.forEach((section) => { + const moveToNewRegion = Array.from(section.addresses).some((addr) => selectedAddresses.has(addr)); + + if (moveToNewRegion) { + newRegionSections.push(section); + } else { + remaining.push(section); + } + }); + + if (remaining.length) { + /* + If the user selected the same color as this region, + and only some sections remain, we split those remaining + sections into individual single-section regions. + + This prevents color collisions and preserves uniqueness. + */ + if (region.colorIndexNumber === selectedRegionColorIndex) { + remaining.forEach((section) => { + regions.push({ + ...region, + sectionGroups: [section], + colorIndexNumber: undefined + }); + }); + } else { + // Normal case: keep region with its remaining sections + regions.push({ + ...region, + sectionGroups: remaining + }); + } + } + }); + // ---------------------------------- + // Validation: must combine at least two sections + // ---------------------------------- + + if (newRegionSections.length < 2) { + setFieldError( + `labware[${labwareIndex}].selectedAddresses`, + 'Please select two or more slots or sections to combine into a single region.' + ); + return; + } + + // ---------------------------------- + // Insert newly created region + // ---------------------------------- + + regions.push({ + roi: '', + sectionGroups: newRegionSections, + colorIndexNumber: selectedRegionColorIndex + }); + // ---------------------------------- + // Update formik values after renaming and re-indexing regions + // ---------------------------------- + await setValues((prev) => ({ + ...prev, + labware: prev.labware.map((labware, index) => + index === labwareIndex + ? { + ...labware, + selectedAddresses: undefined, + selectedRegionColorIndex: undefined, + regions: reIndexAndRenameRegions(regions, runName, sgpNumber) + } + : labware + ) + })); + + deselectLabwareSlots(); + }; + + /** + * Removes the region associated with the currently selected color. + * + * Behavior: + * - Finds the region whose colorIndexNumber matches the selected color. + * - Deletes that region. + * - Converts each of its sections into its own standalone single-section region. + * - Re-indexes and renames all remaining regions. + * - Clears the selected region color in form state. + * + * Validation: + * - A region color must be selected. + * - The selected color must correspond to an existing region. + */ + const removeRegion = async () => { + const selectedRegionColorIndex = analyserLabware.selectedRegionColorIndex; + // ---------------------------------- + // Validate selected color + // ---------------------------------- + if (selectedRegionColorIndex === undefined) { + setFieldError( + `labware[${labwareIndex}].selectedRegionColorIndex`, + 'Please select a region color before removing a region.' + ); + return; + } + // ---------------------------------- + // Locate region to remove + // ---------------------------------- + const regionToRemove = analyserLabware.regions.find( + (region) => region.colorIndexNumber === selectedRegionColorIndex + ); + if (!regionToRemove) { + setFieldError( + `labware[${labwareIndex}].selectedRegionColorIndex`, + 'The selected region color does not correspond to any existing region.' + ); + return; + } + // ---------------------------------- + // Build new region list + // ---------------------------------- + let remainingRegions = analyserLabware.regions.filter( + (region) => region.colorIndexNumber !== selectedRegionColorIndex + ); + const splitSectionsIntoRegions: Region[] = regionToRemove.sectionGroups.map((section) => ({ + roi: '', + colorIndexNumber: undefined, + sectionGroups: [section] + })); + await setValues((prev) => ({ + ...prev, + labware: prev.labware.map((labware, index) => + index === labwareIndex + ? { + ...labware, + selectedRegionColorIndex: undefined, + regions: reIndexAndRenameRegions( + [...remainingRegions, ...splitSectionsIntoRegions], + values.runName, + labware.workNumber + ) + } + : labware + ) + })); + }; + + return ( +
+ Set Regions +

+ Hold 'Ctrl' (Cmd for Mac) key to select the slots and sections you want to group into a single region, then + click a region color to create it. +

+

+ To remove a region, select the region color that you would like to remove and click 'Remove region'. +

+
+
+ {REGION_BORDER_COLORS.map((borderColor, index) => { + const highlightClass = + analyserLabware.selectedRegionColorIndex === index ? `ring-3 ring-offset-2 ring-gray-700` : ''; + return ( +
{ + await setFieldValue(`labware[${labwareIndex}].selectedRegionColorIndex`, index); + }} + >
+ ); + })} +
+
+ + Remove Region + + setRegion(values.runName, analyserLabware.workNumber)} + className="p-2 shadow-xs text-red-700 underline hover:bg-gray-100 focus:border-sdb-400 focus:shadow-md-outline-sdb active:bg-gray-200 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2" + > + Create/Update Region + +
+
+
+
+ { + if (el) { + labwareRef.current = el; + } + }} + gridDirection={GridDirection.LeftUp} + labware={analyserLabware.labware} + selectionMode={'multi'} + selectable={'non_empty'} + onSelect={async (addresses) => { + if (addresses.length > 0) { + await setFieldValue(`labware[${labwareIndex}].selectedAddresses`, addresses); + } + }} + regions={analyserLabware.regions} + /> +
+
+
+ ); +}; + +export default RegionDefiner; diff --git a/src/components/xeniumMetrics/RoiTable.tsx b/src/components/xeniumMetrics/RoiTable.tsx index 649fc500..843ff4f6 100644 --- a/src/components/xeniumMetrics/RoiTable.tsx +++ b/src/components/xeniumMetrics/RoiTable.tsx @@ -5,15 +5,12 @@ import { LabwareFlaggedFieldsFragment, RoiFieldsFragment } from '../../types/sdk import { alphaNumericSortDefault } from '../../types/stan'; import { sectionGroupsBySample } from '../../lib/helpers/labwareHelper'; import { PlannedSectionDetails } from '../../lib/machines/layout/layoutContext'; - -type RoiTableRow = { - roi: string; - sectionGroups: Array; -}; +import { Region } from '../../pages/XeniumAnalyser'; type RoiTableProps = { - actionColumn: Column; - data: RoiTableRow[]; + actionColumn?: Column; + roiColumn?: Column; + data: Array; }; export const groupRoisByRegionName = (rois: RoiFieldsFragment[]): Record => { @@ -68,17 +65,17 @@ export const mapRoisToSectionGroups = ( }); return roiSectionsMap; }; -const RoiTable = ({ actionColumn, data }: RoiTableProps) => { +const RoiTable = ({ actionColumn, roiColumn, data }: RoiTableProps) => { return ( ] + : [{ Header: 'Region of interest', accessor: 'roi' } as Column]), + { Header: 'External ID', - Cell: ({ row }: { row: Row }) => { + Cell: ({ row }: { row: Row }) => { return (
{row.original.sectionGroups.map((section, index) => { @@ -94,7 +91,7 @@ const RoiTable = ({ actionColumn, data }: RoiTableProps) => { }, { Header: 'Section Number', - Cell: ({ row }: { row: Row }) => { + Cell: ({ row }: { row: Row }) => { return (
{row.original.sectionGroups.map((section, index) => { @@ -110,7 +107,7 @@ const RoiTable = ({ actionColumn, data }: RoiTableProps) => { }, { Header: 'Address(es)', - Cell: ({ row }: { row: Row }) => { + Cell: ({ row }: { row: Row }) => { return (
{row.original.sectionGroups.map((section, index) => { @@ -124,7 +121,7 @@ const RoiTable = ({ actionColumn, data }: RoiTableProps) => { ); } }, - actionColumn as Column + ...(actionColumn ? [actionColumn as Column] : []) ]} data={data} /> diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index ae5614b6..a1d696ad 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -288,6 +288,17 @@ export const SECTION_GROUPS_BG_COLORS = [ 'bg-rose-300', 'bg-slate-300' ]; + +export const REGION_BORDER_COLORS = [ + 'border-black', + 'border-slate-500', + 'border-sky-500', + 'border-teal-500', + 'border-green-500', + 'border-violet-500', + 'border-yellow-500', + 'border-red-500' +]; /** * Get a timestamp as a string * @return timestamp in the format yyyyMMddHHmmss diff --git a/src/pages/XeniumAnalyser.tsx b/src/pages/XeniumAnalyser.tsx index 3b6d21ac..377d1821 100644 --- a/src/pages/XeniumAnalyser.tsx +++ b/src/pages/XeniumAnalyser.tsx @@ -20,9 +20,9 @@ import variants from '../lib/motionVariants'; import Heading from '../components/Heading'; import LabwareScanner from '../components/labwareScanner/LabwareScanner'; import { Form, Formik } from 'formik'; -import FormikInput, { FormikCheckbox } from '../components/forms/Input'; +import FormikInput, { FormikCheckbox, Input } from '../components/forms/Input'; import WorkNumberSelect from '../components/WorkNumberSelect'; -import Table, { TabelSubHeader, TableBody, TableCell, TableHead, TableHeader } from '../components/Table'; +import Table, { TableBody, TableCell, TableHead, TableHeader } from '../components/Table'; import CustomReactSelect from '../components/forms/CustomReactSelect'; import { GridDirection, objectKeys } from '../lib/helpers'; import BlueButton from '../components/buttons/BlueButton'; @@ -34,34 +34,51 @@ import { lotRegx } from './ProbeHybridisationXenium'; import { joinUnique, samplesFromLabwareOrSLot } from '../components/dataTableColumns'; import RemoveButton from '../components/buttons/RemoveButton'; import PassIcon from '../components/icons/PassIcon'; -import Labware from '../components/labware/Labware'; +import Labware from '../components/labwarePerSection/Labware'; import Panel from '../components/Panel'; import WhiteButton from '../components/buttons/WhiteButton'; import { createSessionStorageForLabwareAwaiting } from '../types/stan'; import { BarcodeDisplayer } from '../components/modal/BarcodeDisplayer'; import { findUploadedFiles } from '../lib/services/fileService'; import { sectionGroupsBySample } from '../lib/helpers/labwareHelper'; +import Label from '../components/forms/Label'; +import PinkButton from '../components/buttons/PinkButton'; +import Modal, { ModalBody, ModalFooter } from '../components/Modal'; +import RegionDefiner from '../components/xeniumAnalyser/RegionDefiner'; +import { PlannedSectionDetails } from '../lib/machines/layout/layoutContext'; -/**Sample data type to represent a sample row which includes all fields to be saved and displayed. */ -type SectionWithRegion = { - addresses: Array; - sampleId: number; - sectionNumber?: string; - externalName?: string; +export type Region = { roi: string; + colorIndexNumber?: number; + sectionGroups: Array; }; -type AnalyserLabwareForm = { +export type AnalyserLabwareForm = { labware: LabwareFlaggedFieldsFragment; hybridisation: boolean; workNumber: string; hasSgpNumberLink?: boolean; position?: CassettePosition; - sections: Array; + regions: Array; analyserScanData?: AnalyserScanDataFieldsFragment; + /** + * Temporarily stores the color index selected by the user while defining + * a new region in the RegionDefiner component. + * + * This value is cleared once region creation or removal is completed. + */ + selectedRegionColorIndex?: number; + + /** + * Temporarily stores the set of slot / section addresses selected by the user + * when creating or modifying a region in the RegionDefiner component. + * + * This value is cleared after the region operation completes. + */ + selectedAddresses?: Set; }; -type XeniumAnalyserFormValues = { +export type XeniumAnalyserFormValues = { lotNumberA: string; lotNumberB: string; cellSegmentationLot: string; @@ -70,6 +87,11 @@ type XeniumAnalyserFormValues = { runName: string; repeat: boolean; performed: string; + /** + * Controls whether the RegionDefiner UI is visible. + * When true, the user can create, edit, or remove regions. + */ + showRegionDefiner: boolean; labware: Array; workNumberAll: string; barcodeDisplayerProps?: BarcodeDisplayerProps; @@ -81,6 +103,7 @@ const formInitialValues: XeniumAnalyserFormValues = { lotNumberA: '', cellSegmentationLot: '', equipmentId: undefined, + showRegionDefiner: false, labware: [], performed: '', workNumberAll: '' @@ -91,6 +114,20 @@ type BarcodeDisplayerProps = { warningMessage?: string; }; +export const reIndexAndRenameRegions = (regions: Region[], runName: string, sgpNumber: string): Array => { + let index = 0; + return regions.map((region) => { + const roi: string = + region.sectionGroups.length === 1 && region.sectionGroups[0].source.tissue?.externalName + ? region.sectionGroups[0].source.tissue.externalName + : [sgpNumber, runName, `Region${index++ + 1}`].filter(Boolean).join('_'); + return { + ...region, + roi: roi + }; + }); +}; + const LabwareAnalyserTable = (labwareForm: AnalyserLabwareForm) => { const samples = samplesFromLabwareOrSLot(labwareForm.labware); return ( @@ -180,19 +217,24 @@ const XeniumAnalyser = () => { decodingConsumablesLot: Yup.string() .optional() .matches(/^\d{6}$/, 'Consumables lot number should be a 6-digit number'), + showRegionDefiner: Yup.boolean(), labware: Yup.array() .of( Yup.object().shape({ hybridisation: Yup.boolean(), workNumber: Yup.string().required().label('SGP Number'), position: Yup.string().required(), - sections: Yup.array() + selectedRegionColorIndex: Yup.number(), + selectedAddresses: Yup.array(), + regions: Yup.array() .of( Yup.object().shape({ roi: Yup.string() .required('Region of interest is a required field') .label('ROI') - .max(64, 'Region of interest field should be string of maximum length 64') + .max(64, 'Region of interest field should be string of maximum length 64'), + sectionGroups: Yup.array().min(1).required('At least one section group per region'), + colorIndexNumber: Yup.number() }) ) .required() @@ -204,38 +246,15 @@ const XeniumAnalyser = () => { }); /** - * Builds a list of labware sections with region information. - * - * Sections are derived in two ways: - * 1. Slots that belong to an explicit section group (as defined during the sectioning operation) - * are grouped together under the same section. - * 2. Any remaining slots that are not part of a section group are treated as individual sections. - * - * This ensures that: - * - Explicitly grouped sections are preserved - * - Ungrouped slots are still represented as standalone sections - * - * @param lw - Labware with flagged slot and sample information - * @returns An array of sections, each containing addresses, sample identity, and region metadata + * Builds a list of labware regions. + * Initially, each section group is assigned to its own region. */ - const labwareSections = React.useCallback((lw: LabwareFlaggedFieldsFragment) => { + const labwareRegions = React.useCallback((lw: LabwareFlaggedFieldsFragment) => { const sectionGroups = sectionGroupsBySample(lw); - const groupedAddress = new Set(); - const sections: SectionWithRegion[] = []; - for (const sectionGroup of Object.values(sectionGroups)) { - const addresses = Array.from(sectionGroup.addresses); - addresses.forEach((address) => { - groupedAddress.add(address); - }); - sections.push({ - addresses: addresses, - sampleId: sectionGroup.source.sampleId, - externalName: sectionGroup.source.tissue?.externalName ?? '', - sectionNumber: String(sectionGroup.source.newSection) ?? '', - roi: sectionGroup.source.tissue?.externalName ?? '' - }); - } - return sections; + return Object.values(sectionGroups).map((sectionGroup, index) => ({ + roi: sectionGroup.source.tissue?.externalName ?? `Region${index + 1}`, + sectionGroups: [sectionGroup] + })); }, []); /**This creates the slot related information for the labware */ @@ -270,7 +289,7 @@ const XeniumAnalyser = () => { workNumber: values.workNumberAll, hasSgpNumberLink, position: undefined, - sections: labwareSections(labware), + regions: labwareRegions(labware), analyserScanData: res.analyserScanData }); } @@ -285,7 +304,7 @@ const XeniumAnalyser = () => { workNumber: values.workNumberAll, hasSgpNumberLink, position: undefined, - sections: labwareSections(labware) + regions: labwareRegions(labware) }); return { ...prev }; }); @@ -297,7 +316,7 @@ const XeniumAnalyser = () => { }; setLabwareSampleData(labware); }, - [labwareSections, stanCore] + [labwareRegions, stanCore] ); const hasUploadedFilesInSgpFolder = async (workNumber: string): Promise => { @@ -326,12 +345,14 @@ const XeniumAnalyser = () => { decodingConsumablesLot: values.decodingConsumablesLot, position: lw.position?.toLowerCase() === 'left' ? CassettePosition.Left : CassettePosition.Right, samples: labwareSections - ? labwareSections.sections.flatMap((sectionWithRegion) => - sectionWithRegion.addresses.map((address) => ({ - address, - sampleId: sectionWithRegion.sampleId, - roi: sectionWithRegion.roi - })) + ? labwareSections?.regions!.flatMap((region) => + region.sectionGroups.flatMap((section) => + Array.from(section.addresses).flatMap((address) => ({ + address, + sampleId: section.source.sampleId, + roi: region.roi + })) + ) ) : [] }; @@ -352,7 +373,7 @@ const XeniumAnalyser = () => { }); }} > - {({ values, setValues, isValid }) => ( + {({ values, setValues, isValid, setFieldValue }) => (
Labware @@ -429,7 +450,8 @@ const XeniumAnalyser = () => { labware: prev.labware.map((lw) => ({ ...lw, workNumber, - hasSgpNumberLink + hasSgpNumberLink, + regions: reIndexAndRenameRegions(lw.regions, prev.runName, workNumber) })) }; }); @@ -441,7 +463,25 @@ const XeniumAnalyser = () => {
- +
@@ -487,19 +527,30 @@ const XeniumAnalyser = () => { .map((lw, lwIndex) => (
- +
+ { + await setFieldValue('showRegionDefiner', true); + }} + /> + { + await setFieldValue('showRegionDefiner', true); + }} + > + Define Regions + +
- - - - SGP Number - Cassette Position - Samples - - - - - +
+
+
+
+ { workNumber.length > 0 ? await hasUploadedFilesInSgpFolder(workNumber) : false; - - await setValues((prev: XeniumAnalyserFormValues) => { - const updatedLabware = [...prev.labware]; - updatedLabware[lwIndex] = { - ...updatedLabware[lwIndex], - workNumber, - hasSgpNumberLink - }; - return { ...prev, labware: updatedLabware }; - }); + await setValues((prev) => ({ + ...prev, + labware: prev.labware.map((lw, index) => + index !== lwIndex + ? lw + : { + ...lw, + workNumber, + hasSgpNumberLink, + regions: reIndexAndRenameRegions( + lw.regions, + prev.runName, + workNumber + ) + } + ) + })); }} workNumber={values.labware[lwIndex]?.workNumber} requiredField={true} @@ -538,8 +596,9 @@ const XeniumAnalyser = () => {
)} - - +
+
+ { return { value: val, label: val }; @@ -548,74 +607,95 @@ const XeniumAnalyser = () => { dataTestId={`${lw.labware.barcode}-position`} emptyOption={true} /> - - - -
-
- - Addresses - - - External Id - - - Section number - - - Region - -
- {lw.sections.map((section, sectionIndex) => { - return ( -
- - - +
+
+
+
+
+ + + Region + External Id + Section Number + Address(es) + + + + {values.labware[lwIndex].regions.map((region, regionIndex) => ( + + +
) => { const barcode = e.target.value.trim(); if (barcode.length !== 0) { - const barcodeDisplayerProps = { - barcode, - warningMessage: - barcode !== section.externalName - ? 'The region does not match the sample external name' - : undefined - }; - await setValues((prev) => ({ - ...prev, - barcodeDisplayerProps - })); + await setFieldValue('barcodeDisplayerProps', { barcode }); } }} />
- ); - })} - -
- - -
+ + +
+ {region.sectionGroups.map((section, index) => { + return ( + + ); + })} +
+
+ +
+ {region.sectionGroups.map((section, index) => { + return ( + + ); + })} +
+
+ +
+ {region.sectionGroups.map((section, index) => { + return ( + + ); + })} +
+
+ + ))} + + +
+
+ + + + + + { + await setFieldValue('showRegionDefiner', false); + }} + > + Done + + + ))}
diff --git a/tests/unit/components/RegionDefiner.spec.tsx b/tests/unit/components/RegionDefiner.spec.tsx new file mode 100644 index 00000000..307d04ef --- /dev/null +++ b/tests/unit/components/RegionDefiner.spec.tsx @@ -0,0 +1,214 @@ +import { AnalyserLabwareForm, Region, XeniumAnalyserFormValues } from '../../../src/pages/XeniumAnalyser'; +import { Formik } from 'formik'; +import RegionDefiner from '../../../src/components/xeniumAnalyser/RegionDefiner'; +import { act, cleanup, fireEvent, render, RenderResult } from '@testing-library/react'; + +import { createFlaggedLabware } from '../../../src/mocks/handlers/flagLabwareHandlers'; +import '@testing-library/jest-dom'; +import { toast } from 'react-toastify'; +import WarningToast from '../../../src/components/notifications/WarningToast'; +import { isSlotFilled } from '../../../src/lib/helpers/slotHelper'; +import resetAllMocks = jest.resetAllMocks; + +// Mock the toast module +jest.spyOn(toast, 'warning'); +jest.mock('../../../src/components/notifications/WarningToast', () => ({ + __esModule: true, + default: jest.fn() +})); + +type renderingOptions = { + selectedAddresses?: Set; + selectedRegionColorIndex?: number; + regions?: Array; +}; + +const labware = createFlaggedLabware('STAN-6426'); + +const labwareAnalyser = ({ + selectedAddresses, + selectedRegionColorIndex, + regions +}: renderingOptions): AnalyserLabwareForm => ({ + labware: labware, + hybridisation: true, + workNumber: 'SGP1001', + regions: + regions ?? + labware.slots + .filter((slot) => isSlotFilled(slot)) + .map((slot, index) => ({ + roi: `Region${index + 1}`, + sectionGroups: [ + { + source: { + sampleId: slot.samples[0].id, + labware: labware, + newSection: 1, + tissue: slot.samples[0].tissue + }, + addresses: new Set([slot.address]) + } + ] + })), + selectedAddresses, + selectedRegionColorIndex +}); +const formValues = ({ + selectedAddresses, + selectedRegionColorIndex, + regions +}: renderingOptions): XeniumAnalyserFormValues => ({ + runName: '', + repeat: false, + lotNumberB: '', + lotNumberA: '', + cellSegmentationLot: '', + equipmentId: undefined, + showRegionDefiner: false, + labware: [labwareAnalyser({ selectedAddresses, selectedRegionColorIndex, regions })], + performed: '', + workNumberAll: '' +}); + +let labwareRegions: Array = []; + +const labwareIndex = 0; + +const renderRegionDefiner = ({ selectedAddresses, selectedRegionColorIndex, regions }: renderingOptions) => { + return render( + + {({ values }) => { + labwareRegions = values['labware'][labwareIndex].regions; + return ; + }} + + ); +}; + +let utils: RenderResult; + +describe('RegionDefiner component', () => { + afterEach(() => { + resetAllMocks(); + cleanup(); + }); + describe('form is rendered correctly', () => { + beforeEach(() => { + utils = renderRegionDefiner({ selectedAddresses: new Set(), selectedRegionColorIndex: 0 }); + }); + it('inits labware regions correctly', () => { + expect(labwareRegions).toHaveLength(40); + }); + }); + describe('creating a new region', () => { + describe('when no slots are selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({ selectedAddresses: new Set(), selectedRegionColorIndex: 0 }); + }); + it('displays a user error message', () => { + act(() => { + utils.getByTestId('create-update-region-button').click(); + }); + expect(WarningToast).toHaveBeenCalled(); + }); + }); + describe('when one slots is selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({ selectedAddresses: new Set(['A1']), selectedRegionColorIndex: 0 }); + }); + it('displays a user error message', () => { + act(() => { + utils.getByTestId('create-update-region-button').click(); + }); + expect(WarningToast).toHaveBeenCalled(); + }); + }); + describe('when no region color is selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({ selectedAddresses: new Set(['A1', 'A2']) }); + }); + it('displays a user error message', () => { + act(() => { + fireEvent.click(utils.getByTestId('create-update-region-button')); + }); + expect(WarningToast).toHaveBeenCalled(); + }); + }); + describe('when a couple or more of slots are selected and a region color is selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({ selectedAddresses: new Set(['A1', 'A2']), selectedRegionColorIndex: 0 }); + }); + it('updates labware layout accordingly', () => { + act(() => { + fireEvent.click(utils.getByTestId('create-update-region-button')); + }); + + expect(WarningToast).not.toHaveBeenCalled(); + expect(utils.getByTestId('slot-wrapper-A1')).toHaveClass('border-black'); + expect(utils.getByTestId('slot-wrapper-A2')).toHaveClass('border-black'); + expect(labwareRegions).toHaveLength(39); + }); + }); + }); + + describe('removing a region', () => { + afterEach(() => { + resetAllMocks(); + cleanup(); + }); + describe('when no region color is selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({}); + }); + it('displays a user error message', () => { + act(() => { + fireEvent.click(utils.getByTestId('remove-region-button')); + }); + expect(WarningToast).toHaveBeenCalled(); + }); + }); + describe('when a region color is selected', () => { + beforeEach(() => { + utils = renderRegionDefiner({ + selectedRegionColorIndex: 0, + regions: [ + { + roi: 'Region1', + colorIndexNumber: 0, + sectionGroups: [ + { + source: { + sampleId: labware.slots[0].samples[0].id, + labware: labware, + newSection: 1, + tissue: labware.slots[0].samples[0].tissue + }, + addresses: new Set(['A1', 'A2']) + }, + { + source: { + sampleId: labware.slots[1].samples[0].id, + labware: labware, + newSection: 2, + tissue: labware.slots[1].samples[0].tissue + }, + addresses: new Set(['B1', 'B2']) + } + ] + } + ] + }); + }); + it('updates labware layout accordingly', () => { + act(() => { + fireEvent.click(utils.getByTestId('remove-region-button')); + }); + expect(WarningToast).not.toHaveBeenCalled(); + expect(utils.getByTestId('slot-wrapper-A1')).not.toHaveClass('border-black'); + expect(utils.getByTestId('slot-wrapper-A2')).not.toHaveClass('border-black'); + expect(labwareRegions).toHaveLength(2); + }); + }); + }); +});