From b0ca7b8cc1f0490781b33576fe9f5acecfbd2c5c Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Fri, 24 Apr 2026 20:49:04 -0700 Subject: [PATCH 1/5] Switch from GWT to React --- .../PlateTemplateDesigner.scss | 608 ++++++++++++++++++ .../PlateTemplateDesigner.tsx | 393 +++++++++++ .../src/client/PlateTemplateDesigner/app.tsx | 20 + .../components/GroupTypesPanel.tsx | 280 ++++++++ .../components/ShiftPanel.tsx | 28 + .../components/StatusBar.tsx | 32 + .../components/TemplateGrid.tsx | 125 ++++ .../components/WarningPanel.tsx | 31 + .../components/WellGroupProperties.tsx | 107 +++ .../src/client/PlateTemplateDesigner/dev.tsx | 21 + .../client/PlateTemplateDesigner/models.ts | 67 ++ .../PlateTemplateDesigner/typings/main.d.ts | 14 + assay/src/client/entryPoints.js | 8 + .../src/org/labkey/assay/PlateController.java | 375 ++++++++++- .../src/org/labkey/assay/plate/PlateImpl.java | 2 +- 15 files changed, 2107 insertions(+), 4 deletions(-) create mode 100644 assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss create mode 100644 assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/app.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/dev.tsx create mode 100644 assay/src/client/PlateTemplateDesigner/models.ts create mode 100644 assay/src/client/PlateTemplateDesigner/typings/main.d.ts diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss new file mode 100644 index 00000000000..3a790af9729 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss @@ -0,0 +1,608 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ + +.plate-template-designer { + padding: 12px 16px; + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + + &__error { + color: #c00; + padding: 16px; + } + + &__loading { + padding: 16px; + color: #666; + } + + &__header { + margin: 10px 0; + } + + &__name-label { + font-weight: bold; + } + + &__name-input { + margin-left: 8px; + padding: 4px 6px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + width: 280px; + } + + &__body { + display: flex; + gap: 16px; + align-items: flex-start; + } + + &__left { + flex: 0 0 auto; + } + + &__right { + flex: 1; + min-width: 200px; + } +} + +// StatusBar +.status-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0 10px; + border-bottom: 1px solid #ddd; + margin-bottom: 8px; + + &__btn { + padding: 5px 14px; + border: 1px solid #aaa; + border-radius: 3px; + background: #f5f5f5; + cursor: pointer; + font-size: 13px; + + &:hover:not(:disabled) { + background: #e8e8e8; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &--primary { + background: #337ab7; + border-color: #2e6da4; + color: #fff; + + &:hover:not(:disabled) { + background: #286090; + } + } + } + + &__dirty { + color: #c80; + font-style: italic; + } + + &__status { + color: #555; + } +} + +// GroupTypesPanel +.group-types-panel { + border: 1px solid #ccc; + border-radius: 3px; + + &__tabs { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid #ccc; + background: #f0f0f0; + } + + &__tab { + padding: 5px 10px; + border: none; + background: transparent; + cursor: pointer; + font-size: 12px; + border-right: 1px solid #ddd; + + &:hover { + background: #e0e0e0; + } + + &--active { + background: #fff; + font-weight: bold; + border-bottom: 2px solid #337ab7; + } + } + + &__tab-body { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px; + } + + &__groups { + flex: 0 0 160px; + min-width: 280px; + min-height: 60px; + } + + &__group { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + margin-bottom: 3px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + min-width: 0; + + &:hover { + background: #f0f0f0; + } + + &--active { + border-color: #337ab7; + background: #e8f0fb; + } + } + + &__color-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + border: 1px solid rgba(0, 0, 0, 0.2); + flex-shrink: 0; + } + + &__group-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + &__rename-input { + flex: 1; + padding: 1px 4px; + border: 1px solid #337ab7; + border-radius: 3px; + font-size: 12px; + min-width: 0; + } + + &__group-actions { + display: flex; + gap: 2px; + margin-left: auto; + flex-shrink: 0; + } + + &__action-btn { + padding: 2px 5px; + border: 1px solid #ccc; + border-radius: 2px; + background: transparent; + cursor: pointer; + font-size: 11px; + line-height: 1; + color: #555; + + &:hover { + background: #e0e0e0; + } + + &--delete { + color: #c00; + + &:hover { + background: #ffe0e0; + } + } + } + + &__create-row { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; + } + + &__new-name-input { + flex: 1; + padding: 3px 5px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + min-width: 0; + } + + &__add-btn { + padding: 3px 8px; + font-size: 12px; + border: 1px solid #aaa; + border-radius: 3px; + background: #f5f5f5; + cursor: pointer; + color: #333; + white-space: nowrap; + + &:hover:not(:disabled) { + background: #e8e8e8; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &--primary { + background: #337ab7; + border-color: #2e6da4; + color: #fff; + + &:hover:not(:disabled) { + background: #286090; + } + } + } +} + +// Multi-create dialog +.multi-create-dialog { + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + padding: 20px; + min-width: 300px; + + &__overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + &__title { + font-weight: bold; + font-size: 14px; + margin-bottom: 14px; + } + + &__table { + width: 100%; + border-collapse: collapse; + + td { + padding-bottom: 10px; + } + } + + &__label { + padding: 6px 16px 6px 0; + white-space: nowrap; + font-size: 13px; + vertical-align: middle; + } + + &__input { + padding: 3px 5px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + + &--count { + width: 80px; + } + } + + &__error { + color: #c00; + font-size: 12px; + margin-top: 2px; + } + + &__buttons { + padding-top: 10px; + display: flex; + gap: 6px; + justify-content: flex-end; + } +} + +// Wrapper for grid + shift panel +.plate-grid-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +// ShiftPanel +.shift-panel { + &__grid { + display: grid; + grid-template-columns: repeat(3, 26px); + grid-template-rows: repeat(3, 26px); + gap: 2px; + align-items: center; + justify-items: center; + } + + &__btn { + width: 26px; + height: 26px; + padding: 0; + border: 1px solid #aaa; + border-radius: 3px; + background: #f5f5f5; + cursor: pointer; + font-size: 14px; + line-height: 1; + + &:hover { + background: #e8e8e8; + } + } + + &__label { + font-size: 10px; + color: #666; + text-align: center; + line-height: 1; + } +} + +// TemplateGrid +.template-grid { + user-select: none; + + &__table { + border-collapse: collapse; + border: 1px solid #aaa; + } + + &__corner { + width: 24px; + height: 24px; + } + + &__col-header { + text-align: center; + font-size: 11px; + font-weight: bold; + width: 28px; + height: 22px; + background: #f0f0f0; + border: 1px solid #ccc; + } + + &__row-header { + text-align: center; + font-size: 11px; + font-weight: bold; + width: 22px; + background: #f0f0f0; + border: 1px solid #ccc; + } + + &__cell { + width: 28px; + height: 28px; + border: 1px solid #ccc; + cursor: crosshair; + transition: filter 0.1s; + + &:hover { + filter: brightness(0.88); + } + + &--active { + outline: 2px solid #333; + outline-offset: -2px; + } + } +} + +// Right panel tabs +.right-panel-tabs { + display: flex; + border-bottom: 1px solid #ccc; + margin-bottom: 8px; + + &__tab { + padding: 5px 10px; + border: none; + background: transparent; + cursor: pointer; + font-size: 12px; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + + &:hover { + background: #f0f0f0; + } + + &--active { + font-weight: bold; + border-bottom-color: #337ab7; + background: #fff; + } + + &--warn { + color: #c80; + } + + &--active#{&}--warn { + border-bottom-color: #c80; + } + } +} + +// WellGroupProperties +.well-group-properties { + border: 1px solid #ccc; + border-radius: 3px; + padding: 10px; + min-height: 60px; + + &--empty { + color: #888; + font-style: italic; + } + + &__title { + font-weight: bold; + margin-bottom: 8px; + } + + &__no-props { + color: #888; + font-style: italic; + font-size: 12px; + } + + &__table { + width: 100%; + border-collapse: collapse; + } + + &__key { + font-weight: bold; + padding: 3px 6px 3px 0; + white-space: nowrap; + width: 40%; + } + + &__value-cell { + padding: 2px 4px 2px 0; + } + + &__action-cell { + padding: 2px 0 2px 6px; + white-space: nowrap; + width: 1%; + } + + &__value { + padding: 3px 4px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + width: 100%; + box-sizing: border-box; + } + + &__delete-btn { + padding: 2px 5px; + border: 1px solid #ccc; + border-radius: 2px; + background: transparent; + cursor: pointer; + font-size: 11px; + line-height: 1; + color: #c00; + + &:hover { + background: #ffe0e0; + } + } + + &__add-row td { + border-top: 1px solid #eee; + padding-top: 6px; + } + + &__new-key { + padding: 3px 5px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + width: 100%; + box-sizing: border-box; + } + + &__new-value { + padding: 3px 5px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + width: 100%; + box-sizing: border-box; + } + + &__add-btn { + padding: 3px 8px; + border: 1px solid #aaa; + border-radius: 3px; + background: #f5f5f5; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + + &:hover:not(:disabled) { + background: #e8e8e8; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } + } +} + +// WarningPanel +.warning-panel { + border: 1px solid #e8a000; + border-radius: 3px; + padding: 10px; + background: #fffbe6; + + &__title { + font-weight: bold; + color: #c80; + margin-bottom: 6px; + } + + &__none { + color: #888; + font-style: italic; + font-size: 12px; + } + + &__list { + margin: 0; + padding-left: 18px; + } + + &__item { + color: #7a5800; + font-size: 12px; + margin-bottom: 3px; + } +} diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx new file mode 100644 index 00000000000..16297b36f3e --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActionURL, Ajax, Utils } from '@labkey/api'; + +import { PlateTemplate, WellGroup, computeWarnings } from './models'; +import { StatusBar } from './components/StatusBar'; +import { GroupTypesPanel } from './components/GroupTypesPanel'; +import { ShiftPanel } from './components/ShiftPanel'; +import { TemplateGrid } from './components/TemplateGrid'; +import { WellGroupProperties } from './components/WellGroupProperties'; +import { WarningPanel } from './components/WarningPanel'; + +import './PlateTemplateDesigner.scss'; + +const COLORS = [ + '#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', + '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac', + '#6ba3be', '#ffbe7d', '#ff9d9a', '#86bcb6', '#8cd17d', + '#f1ce63', '#d4a6c8', '#ffb7c5', '#c7a97e', '#d7d5cf', +]; + +function assignColors(groups: WellGroup[]): Map { + const map = new Map(); + groups.forEach((g, i) => { + map.set(g.rowId, COLORS[i % COLORS.length]); + }); + return map; +} + +export function PlateTemplateDesigner(): JSX.Element { + const [plate, setPlate] = useState(null); + const [activeGroup, setActiveGroup] = useState(null); + const [activeTab, setActiveTab] = useState(''); + const [rightTab, setRightTab] = useState<'properties' | 'warnings'>('properties'); + const [isDirty, setIsDirty] = useState(false); + const [status, setStatus] = useState(''); + const [colorMap, setColorMap] = useState>(new Map()); + const [error, setError] = useState(null); + const plateNameRef = useRef(''); + const statusTimerRef = useRef | null>(null); + const nextGroupIdRef = useRef(-1); + + useEffect(() => { + const templateName = ActionURL.getParameter('templateName'); + const plateIdStr = ActionURL.getParameter('plateId'); + const assayType = ActionURL.getParameter('assayType'); + const templateType = ActionURL.getParameter('templateType'); + const rowCountStr = ActionURL.getParameter('rowCount'); + const colCountStr = ActionURL.getParameter('colCount'); + const copy = ActionURL.getParameter('copy') === 'true' || ActionURL.getParameter('copyTemplate') === 'true'; + + const params: Record = {}; + if (templateName) params.templateName = templateName; + if (plateIdStr) params.plateId = parseInt(plateIdStr, 10); + if (assayType) params.assayType = assayType; + if (templateType) params.templateType = templateType; + if (rowCountStr) params.rowCount = parseInt(rowCountStr, 10); + if (colCountStr) params.colCount = parseInt(colCountStr, 10); + params.copy = copy; + + Ajax.request({ + url: ActionURL.buildURL('plate', 'getTemplateDefinition.api'), + method: 'GET', + params, + success: Utils.getCallbackWrapper((response: { data: PlateTemplate }) => { + const plate = response.data; + plateNameRef.current = plate.defaultPlateName || plate.name || ''; + setPlate({ ...plate, name: plateNameRef.current }); + setColorMap(assignColors(plate.groups)); + setActiveTab(plate.groupTypes[0] ?? ''); + if (plate.copyMode) setIsDirty(true); + }), + failure: Utils.getCallbackWrapper((response: any) => { + setError(response?.exception ?? 'Failed to load plate template.'); + }, null, true), + }); + }, []); + + const handleNameChange = useCallback((name: string) => { + plateNameRef.current = name; + setPlate(prev => prev ? { ...prev, name } : null); + setIsDirty(true); + }, []); + + const handleGroupSelect = useCallback((group: WellGroup) => { + setActiveGroup(group); + }, []); + + const handleCellAssign = useCallback((row: number, col: number) => { + if (!activeGroup || !plate) return; + setPlate(prev => { + if (!prev) return null; + const updatedGroups = prev.groups.map(g => { + if (g.rowId === activeGroup.rowId) { + const alreadyHas = g.positions.some(p => p.row === row && p.col === col); + if (alreadyHas) return g; + return { ...g, positions: [...g.positions, { row, col }] }; + } + if (g.type === activeGroup.type) { + // Remove from other groups of the same type to avoid conflicts + return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; + } + return g; + }); + return { ...prev, groups: updatedGroups }; + }); + setIsDirty(true); + }, [activeGroup, plate]); + + const handleCellToggle = useCallback((row: number, col: number) => { + if (!activeGroup || !plate) return; + setPlate(prev => { + if (!prev) return null; + const updatedGroups = prev.groups.map(g => { + if (g.rowId === activeGroup.rowId) { + const hasCell = g.positions.some(p => p.row === row && p.col === col); + if (hasCell) { + return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; + } + return { ...g, positions: [...g.positions, { row, col }] }; + } + if (g.type === activeGroup.type) { + return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; + } + return g; + }); + return { ...prev, groups: updatedGroups }; + }); + setIsDirty(true); + }, [activeGroup, plate]); + + const handleAddGroup = useCallback((type: string, name: string) => { + if (!plate) return; + const rowId = nextGroupIdRef.current--; + const newGroup: WellGroup = { + rowId, + type, + name, + positions: [], + properties: {}, + allowNewGroups: plate.canCreateGroupsByType?.[type] ?? false, + }; + setPlate(prev => prev ? { ...prev, groups: [...prev.groups, newGroup] } : null); + setColorMap(prev => { + const next = new Map(prev); + next.set(rowId, COLORS[prev.size % COLORS.length]); + return next; + }); + setActiveGroup(newGroup); + setIsDirty(true); + }, [plate]); + + const handleShift = useCallback((verticalShift: number, horizontalShift: number) => { + setPlate(prev => { + if (!prev) return null; + const { rows, cols } = prev; + const updatedGroups = prev.groups.map(g => { + if (g.type !== activeTab) return g; + return { + ...g, + positions: g.positions.map(p => ({ + row: ((p.row - verticalShift) % rows + rows) % rows, + col: ((p.col - horizontalShift) % cols + cols) % cols, + })), + }; + }); + return { ...prev, groups: updatedGroups }; + }); + setIsDirty(true); + }, [activeTab]); + + const handleDeleteGroup = useCallback((rowId: number) => { + setPlate(prev => prev ? { ...prev, groups: prev.groups.filter(g => g.rowId !== rowId) } : null); + setColorMap(prev => { const next = new Map(prev); next.delete(rowId); return next; }); + setActiveGroup(prev => prev?.rowId === rowId ? null : prev); + setIsDirty(true); + }, []); + + const handleRenameGroup = useCallback((rowId: number, newName: string) => { + setPlate(prev => prev ? { ...prev, groups: prev.groups.map(g => g.rowId === rowId ? { ...g, name: newName } : g) } : null); + setActiveGroup(prev => prev?.rowId === rowId ? { ...prev, name: newName } : prev); + setIsDirty(true); + }, []); + + const handlePropertyChange = useCallback((groupRowId: number, key: string, value: string) => { + setPlate(prev => { + if (!prev) return null; + const updatedGroups = prev.groups.map(g => + g.rowId === groupRowId ? { ...g, properties: { ...g.properties, [key]: value } } : g + ); + return { ...prev, groups: updatedGroups }; + }); + setActiveGroup(prev => prev?.rowId === groupRowId ? { ...prev, properties: { ...prev.properties, [key]: value } } : prev); + setIsDirty(true); + }, []); + + const handleDeleteProperty = useCallback((groupRowId: number, key: string) => { + setPlate(prev => { + if (!prev) return null; + const updatedGroups = prev.groups.map(g => { + if (g.rowId !== groupRowId) return g; + const { [key]: _removed, ...rest } = g.properties; + return { ...g, properties: rest }; + }); + return { ...prev, groups: updatedGroups }; + }); + setActiveGroup(prev => { + if (prev?.rowId !== groupRowId) return prev; + const { [key]: _removed, ...rest } = prev.properties; + return { ...prev, properties: rest }; + }); + setIsDirty(true); + }, []); + + const warningCount = useMemo(() => { + if (!plate?.showWarningPanel) return 0; + return computeWarnings(plate).length; + }, [plate]); + + const navigateAway = useCallback(() => { + const returnURL = ActionURL.getParameter('returnURL') || ActionURL.getParameter('returnUrl'); + const isSameOrigin = (url: string) => { + try { + return new URL(url, window.location.origin).origin === window.location.origin; + } catch { + return false; + } + }; + window.location.href = (returnURL && isSameOrigin(returnURL)) ? returnURL : ActionURL.buildURL('plate', 'plateList'); + }, []); + + const handleSave = useCallback(() => { + if (!plate) return; + setStatus('Saving...'); + Ajax.request({ + url: ActionURL.buildURL('plate', 'saveTemplate.api'), + method: 'POST', + jsonData: plate, + success: Utils.getCallbackWrapper((response: { data: { rowId: number } }) => { + const rowId = response.data.rowId; + setIsDirty(false); + setPlate(prev => prev ? { ...prev, rowId } : null); + // Update URL to canonical form so a refresh reloads this plate + const url = new URL(window.location.href); + url.search = ''; + url.searchParams.set('templateName', plate.name); + url.searchParams.set('plateId', String(rowId)); + window.history.replaceState(null, '', url.toString()); + setStatus('Saved.'); + if (statusTimerRef.current) clearTimeout(statusTimerRef.current); + statusTimerRef.current = setTimeout(() => setStatus(''), 5000); + }), + failure: Utils.getCallbackWrapper((response: any) => { + setStatus('Save failed: ' + (response?.exception ?? 'unknown error')); + }, null, true), + }); + }, [plate]); + + const handleSaveAndClose = useCallback(() => { + if (!plate) return; + if (!isDirty) { + navigateAway(); + return; + } + setStatus('Saving...'); + Ajax.request({ + url: ActionURL.buildURL('plate', 'saveTemplate.api'), + method: 'POST', + jsonData: plate, + success: Utils.getCallbackWrapper(() => { + setIsDirty(false); + navigateAway(); + }), + failure: Utils.getCallbackWrapper((response: any) => { + setStatus('Save failed: ' + (response?.exception ?? 'unknown error')); + }, null, true), + }); + }, [plate, isDirty, navigateAway]); + + const handleCancel = useCallback(() => { + navigateAway(); + }, [navigateAway]); + + // Warn on unsaved navigation + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isDirty]); + + // Keep activeGroup in sync when plate changes + useEffect(() => { + if (activeGroup && plate) { + const updated = plate.groups.find(g => g.rowId === activeGroup.rowId); + if (updated) setActiveGroup(updated); + } + }, [plate]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return
{error}
; + } + + if (!plate) { + return
Loading...
; + } + + return ( +
+ +
+ +
+
+
+ { setActiveTab(tab); setActiveGroup(null); }} + onAddGroup={handleAddGroup} + onDeleteGroup={handleDeleteGroup} + onRenameGroup={handleRenameGroup} + > +
+ + +
+
+
+
+ {plate.showWarningPanel && ( +
+ + +
+ )} + {(!plate.showWarningPanel || rightTab === 'properties') && ( + + )} + {plate.showWarningPanel && rightTab === 'warnings' && ( + + )} +
+
+
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/app.tsx b/assay/src/client/PlateTemplateDesigner/app.tsx new file mode 100644 index 00000000000..03276e83aa1 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/app.tsx @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { getServerContext } from '@labkey/api'; +import { ServerContextProvider, withAppUser } from '@labkey/components'; + +import { PlateTemplateDesigner } from './PlateTemplateDesigner'; + +// Need to wait for container element to be available in labkey wrapper before render +window.addEventListener('DOMContentLoaded', () => { + createRoot(document.getElementById('app')).render( + + + + ); +}); diff --git a/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx new file mode 100644 index 00000000000..fa4d0a21275 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { PlateTemplate, WellGroup } from '../models'; + +interface Props { + plate: PlateTemplate; + activeGroup: WellGroup | null; + activeTab: string; + colorMap: Map; + onGroupSelect: (group: WellGroup) => void; + onTabChange: (tab: string) => void; + onAddGroup: (type: string, name: string) => void; + onDeleteGroup: (rowId: number) => void; + onRenameGroup: (rowId: number, newName: string) => void; + children?: React.ReactNode; +} + +export function GroupTypesPanel({ + plate, + activeGroup, + activeTab, + colorMap, + onGroupSelect, + onTabChange, + onAddGroup, + onDeleteGroup, + onRenameGroup, + children, +}: Props): JSX.Element { + const [newGroupName, setNewGroupName] = useState(''); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [multiCreateOpen, setMultiCreateOpen] = useState(false); + const [multiBaseName, setMultiBaseName] = useState(''); + const [multiCount, setMultiCount] = useState('2'); + const [multiCountError, setMultiCountError] = useState(''); + const multiBaseNameRef = useRef(null); + + const groupsOfType = plate.groups.filter(g => g.type === activeTab); + const canAdd = plate.canCreateGroupsByType?.[activeTab] ?? false; + + const unusedDefaults = useMemo(() => { + const defaults = plate.typesToDefaultGroups[activeTab] ?? []; + return defaults.filter(d => !groupsOfType.some(g => g.name === d)); + }, [plate, activeTab, groupsOfType]); + + // Reset create-input when tab changes + useEffect(() => { + setNewGroupName(unusedDefaults[0] ?? ''); + setRenamingId(null); + }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps + + // Advance to next unused default when the current one gets used + useEffect(() => { + if (unusedDefaults.length > 0 && !unusedDefaults.includes(newGroupName)) { + setNewGroupName(unusedDefaults[0]); + } + }, [unusedDefaults]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleCreate = () => { + const trimmed = newGroupName.trim(); + if (!trimmed) return; + onAddGroup(activeTab, trimmed); + }; + + const openMultiCreate = () => { + setMultiBaseName(newGroupName.trim()); + setMultiCount('2'); + setMultiCountError(''); + setMultiCreateOpen(true); + // Focus the base name input after the modal renders + setTimeout(() => multiBaseNameRef.current?.select(), 0); + }; + + const handleMultiCreate = () => { + const count = parseInt(multiCount, 10); + if (isNaN(count) || count < 1) { + setMultiCountError(`"${multiCount}" is not a valid count.`); + return; + } + const baseName = multiBaseName.trim(); + if (!baseName) return; + for (let i = 1; i <= count; i++) { + onAddGroup(activeTab, `${baseName} ${i}`); + } + setMultiCreateOpen(false); + }; + + const handleDeleteClick = (e: React.MouseEvent, group: WellGroup) => { + e.stopPropagation(); + if (window.confirm(`Delete well group "${group.name}"?`)) { + onDeleteGroup(group.rowId); + } + }; + + const handleRenameClick = (e: React.MouseEvent, group: WellGroup) => { + e.stopPropagation(); + setRenamingId(group.rowId); + setRenameValue(group.name); + }; + + const handleRenameCommit = (rowId: number) => { + const trimmed = renameValue.trim(); + if (trimmed) onRenameGroup(rowId, trimmed); + setRenamingId(null); + }; + + return ( +
+
+ {plate.groupTypes.map(type => ( + + ))} +
+
+
+ {groupsOfType.map(group => { + const color = colorMap.get(group.rowId); + const isActive = activeGroup?.rowId === group.rowId; + const isRenaming = renamingId === group.rowId; + return ( +
{ if (!isRenaming) onGroupSelect(group); }} + > + + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleRenameCommit(group.rowId); + if (e.key === 'Escape') setRenamingId(null); + }} + onBlur={() => handleRenameCommit(group.rowId)} + onClick={e => e.stopPropagation()} + /> + ) : ( + {group.name} + )} + {isActive && !isRenaming && group.allowNewGroups && ( + + + + + )} +
+ ); + })} + {canAdd && ( +
+ {unusedDefaults.length > 0 ? ( + + ) : ( + setNewGroupName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newGroupName.trim()) handleCreate(); }} + /> + )} + + +
+ )} +
+ {children} +
+ {multiCreateOpen && ( +
setMultiCreateOpen(false)}> +
e.stopPropagation()}> +
Create Multiple Groups
+ + + + + + + + + + + + + + +
Base Name + setMultiBaseName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleMultiCreate(); if (e.key === 'Escape') setMultiCreateOpen(false); }} + /> +
Count + { setMultiCount(e.target.value); setMultiCountError(''); }} + onKeyDown={e => { if (e.key === 'Enter') handleMultiCreate(); if (e.key === 'Escape') setMultiCreateOpen(false); }} + /> + {multiCountError &&
{multiCountError}
} +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx new file mode 100644 index 00000000000..889c24f7127 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React from 'react'; + +interface Props { + onShift: (verticalShift: number, horizontalShift: number) => void; +} + +export function ShiftPanel({ onShift }: Props): JSX.Element { + return ( +
+
+ + + + + Shift + + + + +
+
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx b/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx new file mode 100644 index 00000000000..c68b54e9684 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React from 'react'; + +interface Props { + isDirty: boolean; + status: string; + onSaveAndClose: () => void; + onSave: () => void; + onCancel: () => void; +} + +export function StatusBar({ isDirty, status, onSaveAndClose, onSave, onCancel }: Props): JSX.Element { + return ( +
+ + + + {isDirty && Unsaved changes} + {status && {status}} +
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx new file mode 100644 index 00000000000..b4cf732fece --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useCallback, useRef } from 'react'; + +import { PlateTemplate, WellGroup } from '../models'; + +interface Props { + plate: PlateTemplate; + activeGroup: WellGroup | null; + activeTab: string; + colorMap: Map; + onCellAssign: (row: number, col: number) => void; + onCellToggle: (row: number, col: number) => void; +} + +function getRowLabel(row: number): string { + return String.fromCharCode(65 + row); +} + +function getCellColor(row: number, col: number, activeTab: string, plate: PlateTemplate, colorMap: Map): string | undefined { + // Only color cells belonging to groups of the currently active tab type, + // matching the GWT behavior of showing one type's layout at a time. + let color: string | undefined; + for (const group of plate.groups) { + if (group.type === activeTab && group.positions.some(p => p.row === row && p.col === col)) { + color = colorMap.get(group.rowId); + } + } + return color; +} + +export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAssign, onCellToggle }: Props): JSX.Element { + const isDragging = useRef(false); + const hasMoved = useRef(false); + const startCell = useRef<{ row: number; col: number } | null>(null); + const dragCells = useRef>(new Set()); + + const handleMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => { + if (e.button !== 0) return; + isDragging.current = true; + hasMoved.current = false; + startCell.current = { row, col }; + dragCells.current = new Set([`${row},${col}`]); + e.preventDefault(); + }, []); + + const handleMouseEnter = useCallback((row: number, col: number) => { + if (!isDragging.current) return; + if (!hasMoved.current) { + hasMoved.current = true; + // Deferred: assign the mousedown cell now that we know it's a drag + if (startCell.current) { + onCellAssign(startCell.current.row, startCell.current.col); + } + } + const key = `${row},${col}`; + if (!dragCells.current.has(key)) { + dragCells.current.add(key); + onCellAssign(row, col); + } + }, [onCellAssign]); + + // Called on mouseup over a specific cell — handles click-toggle + const handleCellMouseUp = useCallback((row: number, col: number) => { + if (isDragging.current && !hasMoved.current) { + onCellToggle(row, col); + } + }, [onCellToggle]); + + // Called on the wrapper div — cleans up drag state + const handleDragEnd = useCallback(() => { + isDragging.current = false; + hasMoved.current = false; + startCell.current = null; + dragCells.current = new Set(); + }, []); + + return ( +
+ + + + + ))} + + + + {Array.from({ length: plate.rows }, (_, row) => ( + + + {Array.from({ length: plate.cols }, (_, col) => { + const color = getCellColor(row, col, activeTab, plate, colorMap); + const isActiveGroupCell = activeGroup?.positions.some(p => p.row === row && p.col === col); + const location = `${getRowLabel(row)}${col + 1}`; + const groupForCell = plate.groups.find( + g => g.type === activeTab && g.positions.some(p => p.row === row && p.col === col) + ); + const tooltip = groupForCell ? `${location}: ${groupForCell.name}` : location; + return ( + + ))} + +
+ {Array.from({ length: plate.cols }, (_, col) => ( + {col + 1}
{getRowLabel(row)} handleMouseDown(row, col, e)} + onMouseEnter={() => handleMouseEnter(row, col)} + onMouseUp={() => handleCellMouseUp(row, col)} + /> + ); + })} +
+
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx new file mode 100644 index 00000000000..d297bc1b125 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React from 'react'; + +import { PlateTemplate, computeWarnings } from '../models'; + +interface Props { + plate: PlateTemplate; +} + +export function WarningPanel({ plate }: Props): JSX.Element { + const warnings = computeWarnings(plate); + + return ( +
+
Warnings
+ {warnings.length === 0 ? ( +
No warnings.
+ ) : ( +
    + {warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ )} +
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx b/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx new file mode 100644 index 00000000000..e31158345b7 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useState } from 'react'; + +import { WellGroup } from '../models'; + +interface Props { + activeGroup: WellGroup | null; + onPropertyChange: (groupRowId: number, key: string, value: string) => void; + onDeleteProperty: (groupRowId: number, key: string) => void; +} + +export function WellGroupProperties({ activeGroup, onPropertyChange, onDeleteProperty }: Props): JSX.Element { + const [newKey, setNewKey] = useState(''); + const [newValue, setNewValue] = useState(''); + + if (!activeGroup) { + return ( +
+ Select a well group to view its properties. +
+ ); + } + + const propEntries = Object.entries(activeGroup.properties); + + const handleAdd = () => { + const key = newKey.trim(); + if (!key) return; + onPropertyChange(activeGroup.rowId, key, newValue); + setNewKey(''); + setNewValue(''); + }; + + return ( +
+
{activeGroup.name}
+ + + {propEntries.length === 0 && ( + + + + )} + {propEntries.map(([key, value]) => ( + + + + + + ))} + + + + + + + + +
No properties defined.
{key} + onPropertyChange(activeGroup.rowId, key, e.target.value)} + /> + + +
+ setNewKey(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newKey.trim()) handleAdd(); }} + /> + + setNewValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newKey.trim()) handleAdd(); }} + /> + + +
+
+ ); +} diff --git a/assay/src/client/PlateTemplateDesigner/dev.tsx b/assay/src/client/PlateTemplateDesigner/dev.tsx new file mode 100644 index 00000000000..e0208c5e08a --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/dev.tsx @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { getServerContext } from '@labkey/api'; +import { ServerContextProvider, withAppUser } from '@labkey/components'; + +import { PlateTemplateDesigner } from './PlateTemplateDesigner'; + +const render = () => { + createRoot(document.getElementById('app')).render( + + + + ); +}; + +render(); diff --git a/assay/src/client/PlateTemplateDesigner/models.ts b/assay/src/client/PlateTemplateDesigner/models.ts new file mode 100644 index 00000000000..94e157b72e5 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/models.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface Position { + row: number; + col: number; +} + +export interface WellGroup { + rowId: number; + type: string; + name: string; + positions: Position[]; + properties: Record; + allowNewGroups: boolean; +} + +export interface PlateTemplate { + rowId: number; + name: string; + type: string; + rows: number; + cols: number; + groupTypes: string[]; + canCreateGroupsByType: Record; + groups: WellGroup[]; + plateProperties: Record; + typesToDefaultGroups: Record; + showWarningPanel: boolean; + existingTemplateNames: string[]; + copyMode: boolean; + defaultPlateName: string; +} + +export interface SaveTemplateResponse { + rowId: number; +} + +// Matches GWT TemplateGridCell.getWarnings() logic exactly. +export function computeWarnings(plate: PlateTemplate): string[] { + const cellTypes = new Map>(); + for (const group of plate.groups) { + for (const pos of group.positions) { + const key = `${pos.row},${pos.col}`; + if (!cellTypes.has(key)) cellTypes.set(key, new Set()); + cellTypes.get(key).add(group.type); + } + } + const warnings: string[] = []; + for (const [key, types] of cellTypes.entries()) { + const [row, col] = key.split(',').map(Number); + const cellLabel = `${String.fromCharCode(65 + row)}${col + 1}`; + const hasReplicate = types.has('REPLICATE'); + const hasSpecimen = types.has('SPECIMEN'); + const hasControl = types.has('CONTROL'); + if (hasReplicate && !(hasSpecimen || hasControl)) { + warnings.push(`${cellLabel}: Well is a replicate, but is not part of a specimen or control group.`); + } + if (hasControl && hasSpecimen) { + warnings.push(`${cellLabel}: Well is in both a specimen and a control group.`); + } + } + return warnings; +} diff --git a/assay/src/client/PlateTemplateDesigner/typings/main.d.ts b/assay/src/client/PlateTemplateDesigner/typings/main.d.ts new file mode 100644 index 00000000000..b8a4a9133e6 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/typings/main.d.ts @@ -0,0 +1,14 @@ +/** + * @deprecated Use getServerContext() from @labkey/api instead + */ +declare const LABKEY: import('@labkey/api').LabKey; + +/** + * Needed so we can use process.env.NODE_ENV, which is injected by webpack, but not included in the types declared in + * the browser environments. + */ +declare const process: { + env: { + NODE_ENV: string; + }; +}; diff --git a/assay/src/client/entryPoints.js b/assay/src/client/entryPoints.js index b11f0af9722..e0522e96d3c 100644 --- a/assay/src/client/entryPoints.js +++ b/assay/src/client/entryPoints.js @@ -9,5 +9,13 @@ module.exports = { title: 'New Assay Design', permissionClasses: ['org.labkey.api.assay.security.DesignAssayPermission'], path: './src/client/AssayTypeSelect' + }, { + name: 'plateTemplateDesigner', + title: 'Plate Template Designer', + permissionClasses: [ + 'org.labkey.api.security.permissions.InsertPermission', + 'org.labkey.api.assay.security.DesignAssayPermission' + ], + path: './src/client/PlateTemplateDesigner' }] }; \ No newline at end of file diff --git a/assay/src/org/labkey/assay/PlateController.java b/assay/src/org/labkey/assay/PlateController.java index e33db16cdd4..d35a2655dc3 100644 --- a/assay/src/org/labkey/assay/PlateController.java +++ b/assay/src/org/labkey/assay/PlateController.java @@ -15,11 +15,15 @@ */ package org.labkey.assay; +import org.apache.commons.io.input.BoundedInputStream; import org.apache.logging.log4j.Logger; import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.action.ApiJsonForm; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.action.BaseApiAction; import org.labkey.api.action.FormHandlerAction; +import org.labkey.api.action.NullSafeBindException; import org.labkey.api.action.FormViewAction; import org.labkey.api.action.GWTServiceAction; import org.labkey.api.action.Marshal; @@ -32,9 +36,12 @@ import org.labkey.api.action.SpringActionController; import org.labkey.api.assay.plate.Plate; import org.labkey.api.assay.plate.PlateCustomField; +import org.labkey.api.assay.plate.PlateLayoutHandler; import org.labkey.api.assay.plate.PlateService; import org.labkey.api.assay.plate.PlateSet; import org.labkey.api.assay.plate.PlateType; +import org.labkey.api.assay.plate.Position; +import org.labkey.api.assay.plate.WellGroup; import org.labkey.api.assay.security.DesignAssayPermission; import org.labkey.api.collections.RowMapFactory; import org.labkey.api.data.Container; @@ -62,6 +69,8 @@ import org.labkey.api.util.JsonUtil; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.URLHelper; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; import org.labkey.api.util.logging.LogHelper; import org.labkey.api.view.ActionURL; import org.labkey.api.view.DataViewSnapshotSelectionForm; @@ -76,6 +85,7 @@ import org.labkey.assay.plate.PlateSetExport; import org.labkey.assay.plate.PlateUrls; import org.labkey.assay.plate.TsvPlateLayoutHandler; +import org.labkey.assay.plate.WellGroupImpl; import org.labkey.assay.plate.model.CreatePlateSetOptions; import org.labkey.assay.plate.model.ReformatOptions; import org.labkey.assay.view.AssayGWTView; @@ -91,6 +101,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -122,7 +133,7 @@ public ActionURL getPlateDetailsURL(Container c) } @RequiresPermission(ReadPermission.class) - public static class BeginAction extends SimpleRedirectAction + public static class BeginAction extends SimpleRedirectAction { @Override public ActionURL getRedirectURL(Object o) @@ -211,7 +222,365 @@ public ActionURL getRedirectURL(RowIdForm form) } @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) - public class DesignerAction extends SimpleViewAction + public class GetTemplateDefinitionAction extends ReadOnlyApiAction + { + @Override + public Object execute(DesignerForm form, BindException errors) throws Exception + { + String templateName = form.getTemplateName(); + Long plateId = form.getPlateId(); + boolean copyTemplate = form.isCopy(); + + if (templateName == null && plateId != null) + { + Plate plate = PlateManager.get().getPlate(getContainer(), plateId); + if (plate != null) + { + templateName = plate.getName(); + } + } + + Plate template; + PlateLayoutHandler handler; + + if (templateName != null) + { + if (plateId == null) + throw new Exception("plateId is required when templateName is specified."); + template = PlateService.get().getPlate(getContainer(), plateId); + if (template == null) + throw new NotFoundException("Plate '" + templateName + "' does not exist."); + handler = PlateManager.get().getPlateLayoutHandler(template.getAssayType()); + if (handler == null) + throw new Exception("Plate template type '" + template.getAssayType() + "' does not exist."); + } + else + { + String assayTypeName = form.getAssayType(); + String templateTypeName = form.getTemplateType(); + int rowCount = form.getRowCount(); + int colCount = form.getColCount(); + + handler = PlateManager.get().getPlateLayoutHandler(assayTypeName); + if (handler == null) + throw new Exception("Plate template type '" + assayTypeName + "' does not exist."); + PlateType plateType = PlateService.get().getPlateType(rowCount, colCount); + if (plateType == null) + throw new Exception("The plate type (" + rowCount + " x " + colCount + ") does not exist."); + template = handler.createPlate(templateTypeName, getContainer(), plateType); + } + + // Build groups list + List groups = template.getWellGroups(); + List> groupList = new ArrayList<>(); + for (int i = 0; i < groups.size(); i++) + { + WellGroup group = groups.get(i); + List> positions = new ArrayList<>(); + for (Position position : group.getPositions()) + { + Map pos = new HashMap<>(); + pos.put("row", position.getRow()); + pos.put("col", position.getColumn()); + positions.add(pos); + } + Map groupProps = new HashMap<>(); + for (String propName : group.getPropertyNames()) + { + Object propValue = group.getProperty(propName); + groupProps.put(propName, (propValue == null || propValue == JSONObject.NULL) ? null : propValue.toString()); + } + + int wellGroupId = copyTemplate || group.getRowId() == null ? -1 * (i + 1) : group.getRowId(); + Map g = new HashMap<>(); + g.put("rowId", wellGroupId); + g.put("type", group.getType().name()); + g.put("name", group.getName()); + g.put("positions", positions); + g.put("properties", groupProps); + g.put("allowNewGroups", handler.canCreateNewGroups(group.getType())); + groupList.add(g); + } + + Map templateProperties = new HashMap<>(); + for (String propName : template.getPropertyNames()) + templateProperties.put(propName, template.getProperty(propName) == null ? null : template.getProperty(propName).toString()); + + // Build type list + List typeList = new ArrayList<>(); + for (WellGroup.Type type : handler.getWellGroupTypes()) + typeList.add(type.name()); + + // Build canCreateGroupsByType + Map canCreateGroupsByType = new LinkedHashMap<>(); + for (WellGroup.Type type : handler.getWellGroupTypes()) + canCreateGroupsByType.put(type.name(), handler.canCreateNewGroups(type)); + + // Build typesToDefaultGroups + Map> typesToDefaultGroups = handler.getDefaultGroupsForTypes(); + + // Existing template names in container + List existingTemplateNames = new ArrayList<>(); + for (Plate p : PlateService.get().getPlates(getContainer())) + existingTemplateNames.add(p.getName()); + + long responseRowId = copyTemplate || template.getRowId() == null ? -1 : template.getRowId(); + String defaultPlateName; + if (templateName != null) + { + defaultPlateName = copyTemplate ? getUniqueName(getContainer(), templateName) : templateName; + } + else + { + defaultPlateName = ""; + } + + Map result = new HashMap<>(); + result.put("rowId", responseRowId); + result.put("name", template.getName()); + result.put("type", template.getAssayType()); + result.put("rows", template.getRows()); + result.put("cols", template.getColumns()); + result.put("groupTypes", typeList); + result.put("canCreateGroupsByType", canCreateGroupsByType); + result.put("groups", groupList); + result.put("plateProperties", templateProperties); + result.put("typesToDefaultGroups", typesToDefaultGroups); + result.put("showWarningPanel", handler.showEditorWarningPanel()); + result.put("existingTemplateNames", existingTemplateNames); + result.put("copyMode", copyTemplate); + result.put("defaultPlateName", defaultPlateName); + + return success(result); + } + } + + public static class SaveTemplateForm implements ApiJsonForm + { + private JSONObject _json; + + @Override + public void bindJson(JSONObject json) + { + _json = json; + } + + public JSONObject getJson() + { + return _json != null ? _json : new JSONObject(); + } + } + + @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) + public static class SaveTemplateAction extends MutatingApiAction + { + private static final int MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB + + @Override + protected BaseApiAction.FormAndErrors populateJacksonForm() throws Exception + { + byte[] bytes; + try (BoundedInputStream bounded = BoundedInputStream.builder() + .setInputStream(getViewContext().getRequest().getInputStream()) + .setMaxCount((long) MAX_BODY_BYTES + 1) + .get()) + { + bytes = bounded.readAllBytes(); + } + if (bytes.length > MAX_BODY_BYTES) + throw new ApiUsageException("Request body exceeds maximum allowed size of 10 MB."); + String body = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + JSONObject jsonObj = body.isEmpty() ? new JSONObject() : new JSONObject(body); + SaveTemplateForm form = new SaveTemplateForm(); + form.bindJson(jsonObj); + return new BaseApiAction.FormAndErrors<>(form, new NullSafeBindException(form, "form")); + } + + @Override + public Object execute(SaveTemplateForm form, BindException errors) throws Exception + { + JSONObject json = form.getJson(); + + long rowId = json.optLong("rowId", -1); + String name = json.getString("name"); + String type = json.getString("type"); + int rows = json.getInt("rows"); + int cols = json.getInt("cols"); + JSONArray groupsJson = json.optJSONArray("groups"); + JSONObject platePropsJson = json.optJSONObject("plateProperties"); + + Map plateProperties = new HashMap<>(); + if (platePropsJson != null) + { + for (String key : platePropsJson.keySet()) + plateProperties.put(key, platePropsJson.get(key)); + } + + boolean updateExisting = false; + Plate plate; + if (rowId > 0) + { + plate = PlateManager.get().getPlate(getContainer(), rowId); + if (plate == null) + throw new NotFoundException("Plate template not found: " + rowId); + // Check for a conflicting name from a different plate + Plate conflict = PlateManager.get().getPlateByName(getContainer(), name); + if (conflict != null && !conflict.getRowId().equals(plate.getRowId())) + throw new ApiUsageException("A plate template with name '" + name + "' already exists."); + if (!plate.getAssayType().equals(type)) + throw new ApiUsageException("Plate template type '" + plate.getAssayType() + "' cannot be changed for '" + name + "'"); + if (plate.getRows() != rows || plate.getColumns() != cols) + throw new ApiUsageException("Plate template dimensions cannot be changed for '" + name + "'"); + updateExisting = true; + } + else + { + if (PlateManager.get().getPlateByName(getContainer(), name) != null) + throw new ApiUsageException("A plate template with name '" + name + "' already exists."); + PlateType plateType = PlateService.get().getPlateType(rows, cols); + if (plateType == null) + throw new NotFoundException("The plate type (" + rows + " x " + cols + ") does not exist."); + plate = PlateManager.get().createPlate(getContainer(), type, plateType); + } + + plate.setName(name); + plate.setProperties(plateProperties); + + // Parse groups from JSON + List> submittedGroups = new ArrayList<>(); + Set submittedGroupIds = new HashSet<>(); + if (groupsJson != null) + { + for (int i = 0; i < groupsJson.length(); i++) + { + JSONObject g = groupsJson.getJSONObject(i); + Map gm = new HashMap<>(); + gm.put("rowId", g.optInt("rowId", -1)); + gm.put("type", g.getString("type")); + gm.put("name", g.getString("name")); + JSONArray posArr = g.optJSONArray("positions"); + List positions = new ArrayList<>(); + if (posArr != null) + { + for (int j = 0; j < posArr.length(); j++) + { + JSONObject p = posArr.getJSONObject(j); + positions.add(new int[]{p.getInt("row"), p.getInt("col")}); + } + } + gm.put("positions", positions); + JSONObject propsObj = g.optJSONObject("properties"); + Map props = new HashMap<>(); + if (propsObj != null) + { + for (String key : propsObj.keySet()) + { + Object val = propsObj.get(key); + props.put(key, val == JSONObject.NULL ? null : val); + } + } + gm.put("properties", props); + submittedGroups.add(gm); + if ((int) gm.get("rowId") > 0) + submittedGroupIds.add((int) gm.get("rowId")); + } + } + + // Mark well groups not in submission for deletion + List existingWellGroups = plate.getWellGroups(); + for (WellGroup existingGroup : existingWellGroups) + { + if (existingGroup.getRowId() != null && !submittedGroupIds.contains(existingGroup.getRowId())) + ((PlateImpl) plate).markWellGroupForDeletion(existingGroup); + } + + // Update or create well groups + for (Map gm : submittedGroups) + { + int gRowId = (int) gm.get("rowId"); + String groupTypeName = (String) gm.get("type"); + WellGroup.Type groupType; + try + { + groupType = WellGroup.Type.valueOf(groupTypeName); + } + catch (IllegalArgumentException e) + { + throw new ApiUsageException("Unknown well group type: '" + groupTypeName + "'"); + } + @SuppressWarnings("unchecked") + List posList = (List) gm.get("positions"); + List positions = new ArrayList<>(); + for (int[] p : posList) + positions.add(plate.getPosition(p[0], p[1])); + + @SuppressWarnings("unchecked") + Map props = (Map) gm.get("properties"); + + WellGroupImpl group; + if (updateExisting && gRowId > 0) + { + WellGroupImpl existing = findExistingWellGroup(existingWellGroups, gRowId); + if (existing == null) + throw new Exception("Well group " + gRowId + " was not found."); + if (existing.getType() != groupType) + throw new Exception("Well group type cannot be changed: " + gm.get("name")); + existing.setName((String) gm.get("name")); + existing.setPositions(positions); + ((PlateImpl) plate).storeWellGroup(existing); + group = existing; + } + else + { + group = (WellGroupImpl) plate.addWellGroup((String) gm.get("name"), groupType, positions); + } + group.setProperties(props); + } + + PlateLayoutHandler plateLayoutHandler = PlateManager.get().getPlateLayoutHandler(plate.getAssayType()); + + if (plateLayoutHandler == null) + { + throw new NotFoundException("Invalid assay type"); + } + plateLayoutHandler.validatePlate(getContainer(), getUser(), plate); + long savedRowId = PlateService.get().save(getContainer(), getUser(), plate); + return success(Map.of("rowId", savedRowId)); + } + + private WellGroupImpl findExistingWellGroup(List wellGroups, int rowId) + { + for (WellGroup wg : wellGroups) + { + if (wg.getRowId() != null && wg.getRowId() == rowId) + return (WellGroupImpl) wg; + } + return null; + } + } + + @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) + public static class DesignerAction extends SimpleViewAction + { + @Override + public ModelAndView getView(DesignerForm form, BindException errors) + { + return ModuleHtmlView.get( + ModuleLoader.getInstance().getModule("assay"), + ModuleHtmlView.getGeneratedViewPath("plateTemplateDesigner") + ); + } + + @Override + public void addNavTrail(NavTree root) + { + setHelpTopic("editPlateTemplate"); + root.addChild("Plate Editor"); + } + } + + @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) + public class DesignerGwtAction extends SimpleViewAction { @Override public ModelAndView getView(DesignerForm form, BindException errors) @@ -262,7 +631,7 @@ else if (form.getPlateId() != null) public void addNavTrail(NavTree root) { setHelpTopic("editPlateTemplate"); - root.addChild("Plate Editor"); + root.addChild("Plate Editor (GWT)"); } } diff --git a/assay/src/org/labkey/assay/plate/PlateImpl.java b/assay/src/org/labkey/assay/plate/PlateImpl.java index 5a255cf82ca..9934c2921de 100644 --- a/assay/src/org/labkey/assay/plate/PlateImpl.java +++ b/assay/src/org/labkey/assay/plate/PlateImpl.java @@ -237,7 +237,7 @@ public WellGroup addWellGroup(WellGroupImpl group) } @JsonIgnore - protected WellGroupImpl storeWellGroup(WellGroupImpl group) + public WellGroupImpl storeWellGroup(WellGroupImpl group) { group.setPlate(this); From ca99f5ed0e8ac3cfa68f890d1216aacd71d188e9 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sat, 25 Apr 2026 08:46:49 -0700 Subject: [PATCH 2/5] Auto code review and comments --- .../PlateTemplateDesigner.scss | 100 +++++++++-- .../PlateTemplateDesigner.tsx | 162 +++++++++++++----- .../components/GroupTypesPanel.tsx | 148 +++++++++++++--- .../components/ShiftPanel.tsx | 22 ++- .../components/StatusBar.tsx | 19 +- .../components/TemplateGrid.tsx | 87 +++++++--- .../components/WarningPanel.tsx | 16 +- .../components/WellGroupProperties.tsx | 20 ++- .../client/PlateTemplateDesigner/models.ts | 32 +++- 9 files changed, 479 insertions(+), 127 deletions(-) diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss index 3a790af9729..073aa16695b 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss @@ -4,6 +4,34 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ +/* + * ────────────────────────────────────────────────────────────────────────────── + * Overall layout (vertical stack): + * + * ┌──────────────────────────────────────────────────────────┐ + * │ .status-bar (Save & Close | Save | Cancel | status) │ + * ├──────────────────────────────────────────────────────────┤ + * │ .plate-template-designer__header (Plate Name input) │ + * ├──────────────────────────────────────────────────────────┤ + * │ .plate-template-designer__body (horizontal flex) │ + * │ ┌──────────────────────────────┐ ┌───────────────────┐ │ + * │ │ __left (flex: 0 0 auto) │ │ __right (flex:1) │ │ + * │ │ .group-types-panel │ │ right-panel-tabs │ │ + * │ │ ├─ tab strip │ │ + WellGroupProps │ │ + * │ │ └─ tab-body (flex row) │ │ or WarningPanel│ │ + * │ │ ├─ group list │ │ │ │ + * │ │ └─ .plate-grid-area │ │ │ │ + * │ │ ├─ TemplateGrid │ │ │ │ + * │ │ └─ ShiftPanel │ │ │ │ + * │ └──────────────────────────────┘ └───────────────────┘ │ + * └──────────────────────────────────────────────────────────┘ + * + * The left column is sized to its content (fixed width); the right column grows + * to fill remaining space with a minimum width so it doesn't collapse. + * ────────────────────────────────────────────────────────────────────────────── + */ + +// Root container .plate-template-designer { padding: 12px 16px; font-family: Arial, Helvetica, sans-serif; @@ -36,23 +64,27 @@ width: 280px; } + // Horizontal flex: left (group panel + grid) | right (properties / warnings) &__body { display: flex; gap: 16px; align-items: flex-start; } + // Shrinks/grows to fit GroupTypesPanel content; does not flex &__left { flex: 0 0 auto; } + // Fills remaining width; min-width prevents collapse when the window is narrow &__right { flex: 1; min-width: 200px; } } -// StatusBar +// ── StatusBar ───────────────────────────────────────────────────────────────── +// Pinned above the plate header; uses flex to space buttons and status text. .status-bar { display: flex; align-items: center; @@ -89,17 +121,22 @@ } } + // Unsaved-changes indicator — uses role="status" in JSX for screen reader announcements &__dirty { - color: #c80; + color: #7a5800; font-style: italic; } + // Transient save status ("Saving…", "Saved.") — auto-clears after 5 s &__status { color: #555; } } -// GroupTypesPanel +// ── GroupTypesPanel ─────────────────────────────────────────────────────────── +// Outer panel with a tab strip and a two-column flex body: +// left column: scrollable group list + create controls +// right column: TemplateGrid + ShiftPanel (passed as children) .group-types-panel { border: 1px solid #ccc; border-radius: 3px; @@ -130,6 +167,7 @@ } } + // Flex row: [group list] [grid area] — keeps the grid visually docked to the panel &__tab-body { display: flex; align-items: flex-start; @@ -137,12 +175,14 @@ padding: 8px; } + // Group list column — fixed width with a minimum so short lists don't collapse &__groups { flex: 0 0 160px; min-width: 280px; min-height: 60px; } + // Individual group row — clickable to select; keyboard-accessible via tabIndex + onKeyDown &__group { display: flex; align-items: center; @@ -164,6 +204,7 @@ } } + // Colour indicator matching the group's colour on the grid &__color-swatch { display: inline-block; width: 12px; @@ -181,6 +222,7 @@ min-width: 0; } + // In-place rename input (replaces group-name span when renaming is active) &__rename-input { flex: 1; padding: 1px 4px; @@ -190,6 +232,7 @@ min-width: 0; } + // Rename + delete buttons, visible only for the active group row &__group-actions { display: flex; gap: 2px; @@ -220,6 +263,7 @@ } } + // Row that holds the new-group name input + Create / Create multiple buttons &__create-row { display: flex; align-items: center; @@ -227,6 +271,7 @@ margin-top: 6px; } + // Shared by both the (free-text) variants &__new-name-input { flex: 1; padding: 3px 5px; @@ -267,7 +312,10 @@ } } -// Multi-create dialog +// ── Multi-create dialog ─────────────────────────────────────────────────────── +// Modal overlay + dialog for batch-creating N numbered groups. +// The overlay captures click-outside-to-close; the inner dialog stops propagation. +// Focus is trapped inside the dialog while open (see GroupTypesPanel focus-trap effect). .multi-create-dialog { background: #fff; border: 1px solid #ccc; @@ -301,6 +349,7 @@ } } + // Label cells carry id attributes and are referenced via aria-labelledby on the inputs &__label { padding: 6px 16px 6px 0; white-space: nowrap; @@ -335,7 +384,8 @@ } } -// Wrapper for grid + shift panel +// ── Plate grid area ─────────────────────────────────────────────────────────── +// Centres the TemplateGrid and ShiftPanel as a vertical column inside GroupTypesPanel. .plate-grid-area { display: flex; flex-direction: column; @@ -343,7 +393,9 @@ gap: 8px; } -// ShiftPanel +// ── ShiftPanel ──────────────────────────────────────────────────────────────── +// 3×3 CSS grid with arrow buttons at compass positions and a label in the centre. +// Empty corners use placeholders to maintain grid alignment. .shift-panel { &__grid { display: grid; @@ -378,9 +430,12 @@ } } -// TemplateGrid +// ── TemplateGrid ────────────────────────────────────────────────────────────── +// HTML table where each is a well. The user paints groups by clicking or dragging. +// Background colour comes from the colorMap for the active tab's groups (inline style). +// The --active modifier draws an outline around wells belonging to the selected group. .template-grid { - user-select: none; + user-select: none; // Prevents text selection during drag painting &__table { border-collapse: collapse; @@ -422,6 +477,7 @@ filter: brightness(0.88); } + // Indicates cells belonging to the currently active group &--active { outline: 2px solid #333; outline-offset: -2px; @@ -429,7 +485,9 @@ } } -// Right panel tabs +// ── Right panel tabs ────────────────────────────────────────────────────────── +// Tab strip shown in the right column when showWarningPanel is true. +// --warn colours the Warnings tab amber; --active+--warn shifts the indicator colour too. .right-panel-tabs { display: flex; border-bottom: 1px solid #ccc; @@ -455,16 +513,18 @@ } &--warn { - color: #c80; + color: #7a5800; } &--active#{&}--warn { - border-bottom-color: #c80; + border-bottom-color: #7a5800; } } } -// WellGroupProperties +// ── WellGroupProperties ─────────────────────────────────────────────────────── +// Table-based key/value editor for the active group's property bag. +// The --empty modifier styles the "no group selected" placeholder. .well-group-properties { border: 1px solid #ccc; border-radius: 3px; @@ -472,7 +532,7 @@ min-height: 60px; &--empty { - color: #888; + color: #767676; font-style: italic; } @@ -482,7 +542,7 @@ } &__no-props { - color: #888; + color: #767676; font-style: italic; font-size: 12px; } @@ -492,6 +552,7 @@ border-collapse: collapse; } + // Key column — fixed at 40% width, non-wrapping &__key { font-weight: bold; padding: 3px 6px 3px 0; @@ -506,7 +567,7 @@ &__action-cell { padding: 2px 0 2px 6px; white-space: nowrap; - width: 1%; + width: 1%; // Shrink-wraps to button content } &__value { @@ -533,6 +594,7 @@ } } + // Footer row — Add new property; separated visually from existing entries &__add-row td { border-top: 1px solid #eee; padding-top: 6px; @@ -576,7 +638,9 @@ } } -// WarningPanel +// ── WarningPanel ────────────────────────────────────────────────────────────── +// Amber-tinted panel listing validation warnings from computeWarnings(). +// Only rendered when plate.showWarningPanel is true (assay-type controlled). .warning-panel { border: 1px solid #e8a000; border-radius: 3px; @@ -585,12 +649,12 @@ &__title { font-weight: bold; - color: #c80; + color: #7a5800; margin-bottom: 6px; } &__none { - color: #888; + color: #767676; font-style: italic; font-size: 12px; } diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx index 16297b36f3e..9840731dbb4 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx @@ -4,6 +4,7 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; import { ActionURL, Ajax, Utils } from '@labkey/api'; import { PlateTemplate, WellGroup, computeWarnings } from './models'; @@ -16,6 +17,47 @@ import { WarningPanel } from './components/WarningPanel'; import './PlateTemplateDesigner.scss'; +/** + * Root component of the Plate Template Designer. + * + * ─── User workflow ────────────────────────────────────────────────────────────── + * 1. On mount, URL parameters are read (templateName, plateId, assayType, rowCount, + * colCount, copy) and the plate definition is fetched from the server. + * 2. The user selects a group type tab (e.g. CONTROL, SPECIMEN, REPLICATE). + * 3. Within that type, the user selects or creates a named group. + * 4. The user clicks or drags wells on the grid to paint them onto the active group. + * 5. The user optionally edits well group properties in the right panel. + * 6. "Save" persists without leaving; "Save & Close" saves then navigates to returnURL + * (or the plate list). "Cancel" navigates away without saving. + * + * ─── State architecture ───────────────────────────────────────────────────────── + * `plate` is the single source of truth for all template data. All mutations go + * through `setPlate` with functional updaters to avoid stale-closure bugs. + * + * `activeGroup` is a denormalized mirror of the currently selected group, kept in + * sync with `plate` via the sync effect below. It exists separately because: + * - Callbacks that use `setPlate(prev => ...)` don't have access to the current + * group data inside the updater; they use `activeGroup` from their closure. + * - Components that show the active group (WellGroupProperties, TemplateGrid + * cell highlighting) need a stable reference that doesn't require traversing + * `plate.groups` on every access. + * + * ─── ID conventions ───────────────────────────────────────────────────────────── + * Server-assigned group IDs are positive integers. Client-side created groups + * receive temporary negative IDs (nextGroupIdRef counts down from -1). This ensures + * new groups never collide with existing ones before the first save. The server + * replaces all IDs with permanent values on save; the client does not update + * individual group IDs — only the top-level `plate.rowId` is updated after save. + * + * ─── Cell interaction ─────────────────────────────────────────────────────────── + * Two cell callbacks are distinguished: + * `handleCellAssign` — idempotent add; also evicts the cell from any other group + * of the same type (one cell can only belong to one group per type). Used during + * drag operations. + * `handleCellToggle` — pure on/off; does not steal from siblings. Used for + * single-click (no drag movement). + */ + const COLORS = [ '#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac', @@ -40,9 +82,9 @@ export function PlateTemplateDesigner(): JSX.Element { const [status, setStatus] = useState(''); const [colorMap, setColorMap] = useState>(new Map()); const [error, setError] = useState(null); - const plateNameRef = useRef(''); + const plateNameRef = useRef(''); // Mirrors plate.name; used in save-success to update URL without stale closure const statusTimerRef = useRef | null>(null); - const nextGroupIdRef = useRef(-1); + const nextGroupIdRef = useRef(-1); // Temporary negative IDs for client-created groups (see ID conventions above) useEffect(() => { const templateName = ActionURL.getParameter('templateName'); @@ -233,32 +275,42 @@ export function PlateTemplateDesigner(): JSX.Element { window.location.href = (returnURL && isSameOrigin(returnURL)) ? returnURL : ActionURL.buildURL('plate', 'plateList'); }, []); - const handleSave = useCallback(() => { - if (!plate) return; + /** + * Shared Ajax save logic. Takes the plate snapshot and a success callback to avoid + * duplicating the request setup and failure handler in handleSave / handleSaveAndClose. + * The plate is passed as a parameter (rather than closed over) so callers can pass the + * latest snapshot without worrying about stale state. + */ + const requestSave = useCallback((currentPlate: PlateTemplate, onSuccess: (response: { data: { rowId: number } }) => void) => { setStatus('Saving...'); Ajax.request({ url: ActionURL.buildURL('plate', 'saveTemplate.api'), method: 'POST', - jsonData: plate, - success: Utils.getCallbackWrapper((response: { data: { rowId: number } }) => { - const rowId = response.data.rowId; - setIsDirty(false); - setPlate(prev => prev ? { ...prev, rowId } : null); - // Update URL to canonical form so a refresh reloads this plate - const url = new URL(window.location.href); - url.search = ''; - url.searchParams.set('templateName', plate.name); - url.searchParams.set('plateId', String(rowId)); - window.history.replaceState(null, '', url.toString()); - setStatus('Saved.'); - if (statusTimerRef.current) clearTimeout(statusTimerRef.current); - statusTimerRef.current = setTimeout(() => setStatus(''), 5000); - }), + jsonData: currentPlate, + success: Utils.getCallbackWrapper(onSuccess), failure: Utils.getCallbackWrapper((response: any) => { setStatus('Save failed: ' + (response?.exception ?? 'unknown error')); }, null, true), }); - }, [plate]); + }, []); + + const handleSave = useCallback(() => { + if (!plate) return; + requestSave(plate, (response) => { + const rowId = response.data.rowId; + setIsDirty(false); + setPlate(prev => prev ? { ...prev, rowId } : null); + // Update URL to canonical form so a refresh reloads this plate + const url = new URL(window.location.href); + url.search = ''; + url.searchParams.set('templateName', plateNameRef.current); + url.searchParams.set('plateId', String(rowId)); + window.history.replaceState(null, '', url.toString()); + setStatus('Saved.'); + if (statusTimerRef.current) clearTimeout(statusTimerRef.current); + statusTimerRef.current = setTimeout(() => setStatus(''), 5000); + }); + }, [plate, requestSave]); const handleSaveAndClose = useCallback(() => { if (!plate) return; @@ -266,20 +318,11 @@ export function PlateTemplateDesigner(): JSX.Element { navigateAway(); return; } - setStatus('Saving...'); - Ajax.request({ - url: ActionURL.buildURL('plate', 'saveTemplate.api'), - method: 'POST', - jsonData: plate, - success: Utils.getCallbackWrapper(() => { - setIsDirty(false); - navigateAway(); - }), - failure: Utils.getCallbackWrapper((response: any) => { - setStatus('Save failed: ' + (response?.exception ?? 'unknown error')); - }, null, true), + requestSave(plate, () => { + setIsDirty(false); + navigateAway(); }); - }, [plate, isDirty, navigateAway]); + }, [plate, isDirty, navigateAway, requestSave]); const handleCancel = useCallback(() => { navigateAway(); @@ -297,7 +340,17 @@ export function PlateTemplateDesigner(): JSX.Element { return () => window.removeEventListener('beforeunload', handler); }, [isDirty]); - // Keep activeGroup in sync when plate changes + // Keep activeGroup in sync when plate changes. + // + // Most plate mutations go through setPlate(prev => ...) updaters which don't have + // access to the current activeGroup. After each plate update, this effect finds the + // matching group by rowId and refreshes activeGroup so downstream components (e.g. + // WellGroupProperties, TemplateGrid cell highlight) see the latest data. + // + // activeGroup is intentionally excluded from the deps array: adding it would cause + // an infinite loop (effect sets activeGroup → triggers effect → sets activeGroup …). + // handleDeleteGroup handles the "group no longer exists" case by explicitly setting + // activeGroup to null before this effect can run. useEffect(() => { if (activeGroup && plate) { const updated = plate.groups.find(g => g.rowId === activeGroup.rowId); @@ -346,6 +399,8 @@ export function PlateTemplateDesigner(): JSX.Element { onDeleteGroup={handleDeleteGroup} onRenameGroup={handleRenameGroup} > + {/* The grid and shift panel are passed as children so they render + inside GroupTypesPanel's flex row, visually adjacent to the group list. */}
+ {/* Right panel: WellGroupProperties and (if enabled) a Warnings tab. + The tab strip only renders when showWarningPanel is true; otherwise + WellGroupProperties fills the full right column without tabs. */}
{plate.showWarningPanel && ( -
+
)} {(!plate.showWarningPanel || rightTab === 'properties') && ( - +
+ +
)} {plate.showWarningPanel && rightTab === 'warnings' && ( - +
+ +
)}
diff --git a/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx index fa4d0a21275..19f05b1ea36 100644 --- a/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx @@ -4,6 +4,7 @@ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; import { PlateTemplate, WellGroup } from '../models'; @@ -20,6 +21,48 @@ interface Props { children?: React.ReactNode; } +/** + * Left-hand panel that manages group types (tabs) and individual well groups. + * + * ─── Layout ──────────────────────────────────────────────────────────────────── + * The panel is split into two side-by-side areas via a flex row: + * Left column – the group list + create controls (fixed width) + * Right area – children (the TemplateGrid + ShiftPanel), passed in from the parent + * + * This composition pattern keeps the grid visually anchored inside the panel boundary + * while letting the tab strip and group list scroll independently. + * + * ─── Tab switching ───────────────────────────────────────────────────────────── + * Each tab corresponds to a group type key (e.g. "CONTROL", "SPECIMEN", "REPLICATE"). + * Switching tabs: + * - Clears the active group selection (the parent sets activeGroup to null). + * - Updates the grid to show only that type's colour layout. + * - Resets the create-name input to the first unused default for the new type. + * + * ─── Group selection ─────────────────────────────────────────────────────────── + * Clicking a group row makes it the "active group". Once active, clicking or + * dragging cells on the TemplateGrid paints them onto that group. The active group + * is highlighted with a blue border and shows inline rename/delete actions. + * + * ─── Creating groups ─────────────────────────────────────────────────────────── + * Some group types come with predefined slot names (`typesToDefaultGroups`), e.g. + * "Virus" and "Cell Control" for certain assay types. While unused defaults remain, + * a + * appears for custom names. + * + * "Create multiple…" opens a modal dialog that batch-creates N numbered groups + * (e.g. "Sample 1" through "Sample 8") from a base name and count. Useful for + * assays with many specimens or replicates. + * + * ─── Renaming ────────────────────────────────────────────────────────────────── + * The pencil button activates an inline rename input in place of the group name. + * Blur or Enter commits the change; Escape discards it. + * + * ─── Modal focus trap ────────────────────────────────────────────────────────── + * When the multi-create dialog opens, a useEffect traps Tab/Shift-Tab focus inside + * the dialog and moves initial focus to the first focusable element. Escape closes + * the dialog from anywhere within it. + */ export function GroupTypesPanel({ plate, activeGroup, @@ -40,10 +83,14 @@ export function GroupTypesPanel({ const [multiCount, setMultiCount] = useState('2'); const [multiCountError, setMultiCountError] = useState(''); const multiBaseNameRef = useRef(null); + const dialogRef = useRef(null); - const groupsOfType = plate.groups.filter(g => g.type === activeTab); + // Stable derived list — memoized so useMemo and useEffect deps are stable. + const groupsOfType = useMemo(() => plate.groups.filter(g => g.type === activeTab), [plate, activeTab]); const canAdd = plate.canCreateGroupsByType?.[activeTab] ?? false; + // Predefined slot names not yet occupied by an existing group of this type. + // Drives the toggle in the create row. const unusedDefaults = useMemo(() => { const defaults = plate.typesToDefaultGroups[activeTab] ?? []; return defaults.filter(d => !groupsOfType.some(g => g.name === d)); @@ -62,6 +109,34 @@ export function GroupTypesPanel({ } }, [unusedDefaults]); // eslint-disable-line react-hooks/exhaustive-deps + // Focus trap for multi-create dialog + useEffect(() => { + if (!multiCreateOpen || !dialogRef.current) return; + const dialog = dialogRef.current; + const focusableSelectors = 'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const getFocusable = () => Array.from(dialog.querySelectorAll(focusableSelectors)); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setMultiCreateOpen(false); + return; + } + if (e.key !== 'Tab') return; + const focusable = getFocusable(); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last?.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first?.focus(); } + } + }; + + dialog.addEventListener('keydown', handleKeyDown); + getFocusable()[0]?.focus(); + return () => dialog.removeEventListener('keydown', handleKeyDown); + }, [multiCreateOpen]); + const handleCreate = () => { const trimmed = newGroupName.trim(); if (!trimmed) return; @@ -73,8 +148,7 @@ export function GroupTypesPanel({ setMultiCount('2'); setMultiCountError(''); setMultiCreateOpen(true); - // Focus the base name input after the modal renders - setTimeout(() => multiBaseNameRef.current?.select(), 0); + // Focus is handled by the focus-trap effect above }; const handleMultiCreate = () => { @@ -112,21 +186,27 @@ export function GroupTypesPanel({ return (
-
+
{plate.groupTypes.map(type => ( ))}
-
+
{groupsOfType.map(group => { const color = colorMap.get(group.rowId); @@ -135,11 +215,17 @@ export function GroupTypesPanel({ return (
{ if (!isRenaming) onGroupSelect(group); }} + onKeyDown={e => { + if (!isRenaming && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onGroupSelect(group); + } + }} > setRenameValue(e.target.value)} @@ -161,21 +248,24 @@ export function GroupTypesPanel({ ) : ( {group.name} )} + {/* Rename/delete actions appear only on the active group row */} {isActive && !isRenaming && group.allowNewGroups && ( )} @@ -184,8 +274,14 @@ export function GroupTypesPanel({ })} {canAdd && (
+ {/* + * Show a once all + * defaults are consumed or if there are none defined for this type. + */} {unusedDefaults.length > 0 ? ( {multiCreateOpen && (
setMultiCreateOpen(false)}> -
e.stopPropagation()}> -
Create Multiple Groups
+
e.stopPropagation()} + > +
Create Multiple Groups
- + - + diff --git a/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx index 889c24f7127..43c3d3cb2a0 100644 --- a/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx @@ -9,18 +9,32 @@ interface Props { onShift: (verticalShift: number, horizontalShift: number) => void; } +/** + * A compass-rose control that shifts all wells of the currently active group type one step in any + * cardinal direction. The shift wraps around plate edges (toroidal), so wells that fall off the + * bottom reappear at the top, etc. + * + * Sign convention (matches the modular arithmetic in PlateTemplateDesigner.handleShift): + * verticalShift > 0 → cells move UP (row index decreases: row = (row - shift + rows) % rows) + * verticalShift < 0 → cells move DOWN + * horizontalShift > 0 → cells move LEFT (col index decreases) + * horizontalShift < 0 → cells move RIGHT + * + * Shifts apply to every group of the active type simultaneously, preserving relative layout + * between groups. Only the active tab's type is affected; other types are unchanged. + */ export function ShiftPanel({ onShift }: Props): JSX.Element { return (
- + - + Shift - + - +
diff --git a/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx b/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx index c68b54e9684..9afc2600133 100644 --- a/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx @@ -13,6 +13,21 @@ interface Props { onCancel: () => void; } +/** + * Persistent action bar pinned to the top of the designer. + * + * Button behavior: + * - "Save & Close": saves if dirty, then navigates to the returnURL (or plate list). + * Always enabled so users can leave even when clean. + * - "Save": persists the current state and updates the page URL to the canonical + * ?templateName=...&plateId=... form so a browser refresh reloads the same plate. + * Disabled when the plate is clean to prevent redundant requests. + * - "Cancel": navigates away without saving. The browser's beforeunload handler + * will prompt if there are unsaved changes. + * + * The "Unsaved changes" indicator and transient status text ("Saving…", "Saved.") + * use `role="status"` so screen readers announce them as they appear. + */ export function StatusBar({ isDirty, status, onSaveAndClose, onSave, onCancel }: Props): JSX.Element { return (
@@ -25,8 +40,8 @@ export function StatusBar({ isDirty, status, onSaveAndClose, onSave, onCancel }: - {isDirty && Unsaved changes} - {status && {status}} + {isDirty ? 'Unsaved changes' : ''} + {status}
); } diff --git a/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx index b4cf732fece..49f03c5e388 100644 --- a/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx @@ -3,7 +3,8 @@ * * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import classNames from 'classnames'; import { PlateTemplate, WellGroup } from '../models'; @@ -20,24 +21,61 @@ function getRowLabel(row: number): string { return String.fromCharCode(65 + row); } -function getCellColor(row: number, col: number, activeTab: string, plate: PlateTemplate, colorMap: Map): string | undefined { - // Only color cells belonging to groups of the currently active tab type, - // matching the GWT behavior of showing one type's layout at a time. - let color: string | undefined; - for (const group of plate.groups) { - if (group.type === activeTab && group.positions.some(p => p.row === row && p.col === col)) { - color = colorMap.get(group.rowId); - } - } - return color; -} - +/** + * A scrollable well grid that lets the user paint cells onto the active well group. + * + * ─── Coloring ────────────────────────────────────────────────────────────────── + * Only wells belonging to groups of the *active tab type* are coloured. Wells from + * other types are invisible in the current view. This matches the original GWT + * behaviour of presenting one group type at a time. + * + * ─── Drag / click interaction ────────────────────────────────────────────────── + * Cell assignment uses a three-phase state machine tracked entirely via refs + * (no re-renders on drag): + * + * Phase 1 – mousedown on a cell: + * Enter drag mode. Record the start cell. Do NOT assign it yet — we first need + * to know whether the user is clicking (toggle) or dragging (assign-only). + * + * Phase 2 – mouseenter a *different* cell while dragging: + * We now know it's a drag. Retroactively assign the original start cell + * (deferred assign), then assign each subsequently entered cell. + * `dragCells` deduplicates entries so fast mouse movement can't assign the + * same cell twice. + * + * Phase 3 – mouseup: + * If the pointer never left the start cell (hasMoved === false), treat the + * interaction as a click and toggle that cell (add if absent, remove if present). + * Either way, reset all drag state. + * + * Drag state is also cleaned up on mouseleave of the outer div, preventing stuck + * drag state when the pointer exits the grid. + * + * `onCellAssign` is idempotent (ignores duplicates) and also removes the assigned + * cell from any other group of the same type, enforcing the one-group-per-cell-per- + * type constraint. `onCellToggle` does a pure add/remove with no stealing. + */ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAssign, onCellToggle }: Props): JSX.Element { const isDragging = useRef(false); const hasMoved = useRef(false); const startCell = useRef<{ row: number; col: number } | null>(null); const dragCells = useRef>(new Set()); + // Pre-compute a "row,col" → {color, groupName} map for the active tab type. + // This lets each cell do an O(1) lookup rather than scanning all groups and + // positions on every render (which would be O(groups × positions) per cell). + const positionMap = useMemo(() => { + const map = new Map(); + for (const group of plate.groups) { + if (group.type !== activeTab) continue; + const color = colorMap.get(group.rowId) ?? '#f5f5f5'; + for (const p of group.positions) { + map.set(`${p.row},${p.col}`, { color, groupName: group.name }); + } + } + return map; + }, [plate, activeTab, colorMap]); + const handleMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => { if (e.button !== 0) return; isDragging.current = true; @@ -51,7 +89,8 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAs if (!isDragging.current) return; if (!hasMoved.current) { hasMoved.current = true; - // Deferred: assign the mousedown cell now that we know it's a drag + // Deferred assign: now that we know this is a drag, assign the cell + // the user originally pressed down on. if (startCell.current) { onCellAssign(startCell.current.row, startCell.current.col); } @@ -85,30 +124,26 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAs + ))} {Array.from({ length: plate.rows }, (_, row) => ( - + {Array.from({ length: plate.cols }, (_, col) => { - const color = getCellColor(row, col, activeTab, plate, colorMap); + const entry = positionMap.get(`${row},${col}`); const isActiveGroupCell = activeGroup?.positions.some(p => p.row === row && p.col === col); const location = `${getRowLabel(row)}${col + 1}`; - const groupForCell = plate.groups.find( - g => g.type === activeTab && g.positions.some(p => p.row === row && p.col === col) - ); - const tooltip = groupForCell ? `${location}: ${groupForCell.name}` : location; + const tooltip = entry ? `${location}: ${entry.groupName}` : location; return ( @@ -75,6 +91,7 @@ export function WellGroupProperties({ activeGroup, onPropertyChange, onDeletePro className="well-group-properties__new-key" type="text" placeholder="Property name" + aria-label="Property name" value={newKey} onChange={e => setNewKey(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newKey.trim()) handleAdd(); }} @@ -85,6 +102,7 @@ export function WellGroupProperties({ activeGroup, onPropertyChange, onDeletePro className="well-group-properties__new-value" type="text" placeholder="Value" + aria-label="Property value" value={newValue} onChange={e => setNewValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newKey.trim()) handleAdd(); }} diff --git a/assay/src/client/PlateTemplateDesigner/models.ts b/assay/src/client/PlateTemplateDesigner/models.ts index 94e157b72e5..71036b33950 100644 --- a/assay/src/client/PlateTemplateDesigner/models.ts +++ b/assay/src/client/PlateTemplateDesigner/models.ts @@ -10,12 +10,12 @@ export interface Position { } export interface WellGroup { - rowId: number; - type: string; + rowId: number; // Positive = server-assigned; negative = client-side temp ID (see nextGroupIdRef) + type: string; // Group type key, e.g. "CONTROL", "SPECIMEN", "REPLICATE" name: string; positions: Position[]; properties: Record; - allowNewGroups: boolean; + allowNewGroups: boolean; // Whether the user can create/rename/delete groups of this type } export interface PlateTemplate { @@ -24,14 +24,14 @@ export interface PlateTemplate { type: string; rows: number; cols: number; - groupTypes: string[]; - canCreateGroupsByType: Record; + groupTypes: string[]; // Ordered list of type keys; drives the tab strip + canCreateGroupsByType: Record; // Which types expose the create-group UI groups: WellGroup[]; plateProperties: Record; - typesToDefaultGroups: Record; - showWarningPanel: boolean; + typesToDefaultGroups: Record; // Predefined slot names per type (e.g. "Virus", "Cell Control") + showWarningPanel: boolean; // Set by the server based on assay type config existingTemplateNames: string[]; - copyMode: boolean; + copyMode: boolean; // True when the plate was loaded as a copy; starts the editor in dirty state defaultPlateName: string; } @@ -39,8 +39,22 @@ export interface SaveTemplateResponse { rowId: number; } -// Matches GWT TemplateGridCell.getWarnings() logic exactly. +/** + * Replicates the GWT TemplateGridCell.getWarnings() logic exactly. + * + * Two conditions produce warnings: + * 1. A REPLICATE well that belongs to neither a SPECIMEN nor a CONTROL group is almost certainly + * a configuration error — replicates are only meaningful relative to a specimen or control. + * 2. A well assigned to both a SPECIMEN and a CONTROL group is contradictory; those roles are + * mutually exclusive in LabKey assay semantics. + * + * Notes: + * - Warnings are per-cell, not per-group. + * - A cell can appear in multiple groups of different types (e.g. SPECIMEN + REPLICATE together is fine). + * - Cell labels use spreadsheet notation: row → letter (A=0, B=1, …), col → 1-based number. + */ export function computeWarnings(plate: PlateTemplate): string[] { + // Build a map from cell position → set of group types that include it. const cellTypes = new Map>(); for (const group of plate.groups) { for (const pos of group.positions) { From a4b284f3b5a96c6243434389a43e9cf905bbc175 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sat, 25 Apr 2026 12:06:16 -0700 Subject: [PATCH 3/5] Test fixes --- .../PlateTemplateDesigner.scss | 11 + .../PlateTemplateDesigner.tsx | 90 +++++-- .../components/GroupTypesPanel.tsx | 236 ++++++++++-------- .../components/TemplateGrid.tsx | 52 ++-- 4 files changed, 234 insertions(+), 155 deletions(-) diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss index 073aa16695b..5de546210cf 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss @@ -230,6 +230,17 @@ border-radius: 3px; font-size: 12px; min-width: 0; + + &--error { + border-color: #c00; + } + } + + // Validation error shown below the create row or the rename input + &__name-error { + color: #c00; + font-size: 12px; + margin-top: 2px; } // Rename + delete buttons, visible only for the active group row diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx index 9840731dbb4..8d6ad0ffe8c 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import classNames from 'classnames'; import { ActionURL, Ajax, Utils } from '@labkey/api'; -import { PlateTemplate, WellGroup, computeWarnings } from './models'; +import { PlateTemplate, Position, WellGroup, computeWarnings } from './models'; import { StatusBar } from './components/StatusBar'; import { GroupTypesPanel } from './components/GroupTypesPanel'; import { ShiftPanel } from './components/ShiftPanel'; @@ -85,6 +85,10 @@ export function PlateTemplateDesigner(): JSX.Element { const plateNameRef = useRef(''); // Mirrors plate.name; used in save-success to update URL without stale closure const statusTimerRef = useRef | null>(null); const nextGroupIdRef = useRef(-1); // Temporary negative IDs for client-created groups (see ID conventions above) + // Always-current ref so callbacks can read the latest activeGroup without stale-closure bugs. + const activeGroupRef = useRef(null); + activeGroupRef.current = activeGroup; + const nextColorIndexRef = useRef(0); // Monotonically increasing; never decrements on delete so colors stay unique useEffect(() => { const templateName = ActionURL.getParameter('templateName'); @@ -113,6 +117,11 @@ export function PlateTemplateDesigner(): JSX.Element { plateNameRef.current = plate.defaultPlateName || plate.name || ''; setPlate({ ...plate, name: plateNameRef.current }); setColorMap(assignColors(plate.groups)); + nextColorIndexRef.current = plate.groups.length; + // Initialize below the minimum server rowId to avoid collisions. + // Server IDs should be positive, but guard against zero or negative values. + const minRowId = plate.groups.reduce((min, g) => Math.min(min, g.rowId), 0); + nextGroupIdRef.current = Math.min(-1, minRowId - 1); setActiveTab(plate.groupTypes[0] ?? ''); if (plate.copyMode) setIsDirty(true); }), @@ -132,48 +141,75 @@ export function PlateTemplateDesigner(): JSX.Element { setActiveGroup(group); }, []); - const handleCellAssign = useCallback((row: number, col: number) => { - if (!activeGroup || !plate) return; + // Called on every mouseenter during a drag with the rectangle defined by the + // mousedown cell and the current cell. preDragPositions is the snapshot of the + // active group's positions taken at mousedown (in TemplateGrid), before any drag + // events can modify state. + // + // Select mode (drag started on an empty cell): adds the rectangle to the group's + // pre-drag positions, so existing wells outside the rectangle are preserved. + // Also evicts rectangle cells from sibling groups of the same type. + // + // Unselect mode (drag started on a cell already in the group): removes all + // rectangle cells from the pre-drag positions without affecting other groups. + const handleDragRect = useCallback((r1: number, c1: number, r2: number, c2: number, isUnselect: boolean, preDragPositions: Position[]) => { + const activeGroup = activeGroupRef.current; + if (!activeGroup) return; + const minRow = Math.min(r1, r2); + const maxRow = Math.max(r1, r2); + const minCol = Math.min(c1, c2); + const maxCol = Math.max(c1, c2); + const rectPositions: Position[] = []; + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + rectPositions.push({ row: r, col: c }); + } + } + const rectKeys = new Set(rectPositions.map(p => `${p.row},${p.col}`)); setPlate(prev => { if (!prev) return null; + // Look up the active group's current type from prev to avoid stale-closure issues. + const currentType = prev.groups.find(g => g.rowId === activeGroup.rowId)?.type; const updatedGroups = prev.groups.map(g => { if (g.rowId === activeGroup.rowId) { - const alreadyHas = g.positions.some(p => p.row === row && p.col === col); - if (alreadyHas) return g; - return { ...g, positions: [...g.positions, { row, col }] }; + if (isUnselect) { + // Remove rect from pre-drag snapshot + return { ...g, positions: preDragPositions.filter(p => !rectKeys.has(`${p.row},${p.col}`)) }; + } + // Add rect to pre-drag snapshot (union, deduped) + const preDragKeys = new Set(preDragPositions.map(p => `${p.row},${p.col}`)); + const added = rectPositions.filter(p => !preDragKeys.has(`${p.row},${p.col}`)); + return { ...g, positions: [...preDragPositions, ...added] }; } - if (g.type === activeGroup.type) { - // Remove from other groups of the same type to avoid conflicts - return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; + if (!isUnselect && currentType !== undefined && g.type === currentType) { + // Evict rectangle cells from sibling groups of the same type + return { ...g, positions: g.positions.filter(p => !rectKeys.has(`${p.row},${p.col}`)) }; } return g; }); return { ...prev, groups: updatedGroups }; }); setIsDirty(true); - }, [activeGroup, plate]); + }, []); + // Pure toggle: add the cell if absent, remove it if present const handleCellToggle = useCallback((row: number, col: number) => { - if (!activeGroup || !plate) return; + const activeGroup = activeGroupRef.current; + if (!activeGroup) return; setPlate(prev => { if (!prev) return null; const updatedGroups = prev.groups.map(g => { - if (g.rowId === activeGroup.rowId) { - const hasCell = g.positions.some(p => p.row === row && p.col === col); - if (hasCell) { - return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; - } - return { ...g, positions: [...g.positions, { row, col }] }; - } - if (g.type === activeGroup.type) { + if (g.rowId !== activeGroup.rowId) return g; + const hasCell = g.positions.some(p => p.row === row && p.col === col); + if (hasCell) { return { ...g, positions: g.positions.filter(p => !(p.row === row && p.col === col)) }; } - return g; + return { ...g, positions: [...g.positions, { row, col }] }; }); return { ...prev, groups: updatedGroups }; }); setIsDirty(true); - }, [activeGroup, plate]); + }, []); const handleAddGroup = useCallback((type: string, name: string) => { if (!plate) return; @@ -187,9 +223,10 @@ export function PlateTemplateDesigner(): JSX.Element { allowNewGroups: plate.canCreateGroupsByType?.[type] ?? false, }; setPlate(prev => prev ? { ...prev, groups: [...prev.groups, newGroup] } : null); + const colorIndex = nextColorIndexRef.current++; setColorMap(prev => { const next = new Map(prev); - next.set(rowId, COLORS[prev.size % COLORS.length]); + next.set(rowId, COLORS[colorIndex % COLORS.length]); return next; }); setActiveGroup(newGroup); @@ -352,9 +389,12 @@ export function PlateTemplateDesigner(): JSX.Element { // handleDeleteGroup handles the "group no longer exists" case by explicitly setting // activeGroup to null before this effect can run. useEffect(() => { - if (activeGroup && plate) { + if (!plate) return; + if (activeGroup) { const updated = plate.groups.find(g => g.rowId === activeGroup.rowId); - if (updated) setActiveGroup(updated); + if (updated) { + setActiveGroup(updated); + } } }, [plate]); // eslint-disable-line react-hooks/exhaustive-deps @@ -407,7 +447,7 @@ export function PlateTemplateDesigner(): JSX.Element { activeGroup={activeGroup} activeTab={activeTab} colorMap={colorMap} - onCellAssign={handleCellAssign} + onDragRect={handleDragRect} onCellToggle={handleCellToggle} /> diff --git a/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx index 19f05b1ea36..8b3aae7ae00 100644 --- a/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx @@ -78,6 +78,7 @@ export function GroupTypesPanel({ const [newGroupName, setNewGroupName] = useState(''); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); + const [renameError, setRenameError] = useState(null); const [multiCreateOpen, setMultiCreateOpen] = useState(false); const [multiBaseName, setMultiBaseName] = useState(''); const [multiCount, setMultiCount] = useState('2'); @@ -89,6 +90,9 @@ export function GroupTypesPanel({ const groupsOfType = useMemo(() => plate.groups.filter(g => g.type === activeTab), [plate, activeTab]); const canAdd = plate.canCreateGroupsByType?.[activeTab] ?? false; + // True when the current create-input value is already taken by a group of this type. + const createNameConflicts = newGroupName.trim() !== '' && groupsOfType.some(g => g.name === newGroupName.trim()); + // Predefined slot names not yet occupied by an existing group of this type. // Drives the toggle in the create row. const unusedDefaults = useMemo(() => { @@ -139,8 +143,9 @@ export function GroupTypesPanel({ const handleCreate = () => { const trimmed = newGroupName.trim(); - if (!trimmed) return; + if (!trimmed || createNameConflicts) return; onAddGroup(activeTab, trimmed); + setNewGroupName(''); }; const openMultiCreate = () => { @@ -159,9 +164,14 @@ export function GroupTypesPanel({ } const baseName = multiBaseName.trim(); if (!baseName) return; - for (let i = 1; i <= count; i++) { - onAddGroup(activeTab, `${baseName} ${i}`); + const existingNames = new Set(groupsOfType.map(g => g.name)); + const namesToCreate = Array.from({ length: count }, (_, i) => `${baseName} ${i + 1}`) + .filter(name => !existingNames.has(name)); + if (namesToCreate.length === 0) { + setMultiCountError(`All ${count} generated name${count === 1 ? '' : 's'} already exist in this type.`); + return; } + namesToCreate.forEach(name => onAddGroup(activeTab, name)); setMultiCreateOpen(false); }; @@ -176,12 +186,25 @@ export function GroupTypesPanel({ e.stopPropagation(); setRenamingId(group.rowId); setRenameValue(group.name); + setRenameError(null); }; - const handleRenameCommit = (rowId: number) => { + // revertOnConflict=true: silently discard (used on blur so moving focus away doesn't leave the input frozen). + // revertOnConflict=false: show an inline error and keep the input open (used on Enter so the user sees feedback). + const handleRenameCommit = (rowId: number, revertOnConflict: boolean) => { const trimmed = renameValue.trim(); + if (trimmed && groupsOfType.some(g => g.rowId !== rowId && g.name === trimmed)) { + if (revertOnConflict) { + setRenamingId(null); + setRenameError(null); + } else { + setRenameError(`"${trimmed}" is already used by another group of this type.`); + } + return; + } if (trimmed) onRenameGroup(rowId, trimmed); setRenamingId(null); + setRenameError(null); }; return ( @@ -213,108 +236,125 @@ export function GroupTypesPanel({ const isActive = activeGroup?.rowId === group.rowId; const isRenaming = renamingId === group.rowId; return ( -
{ if (!isRenaming) onGroupSelect(group); }} - onKeyDown={e => { - if (!isRenaming && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - onGroupSelect(group); - } - }} - > - - {isRenaming ? ( - setRenameValue(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') handleRenameCommit(group.rowId); - if (e.key === 'Escape') setRenamingId(null); - }} - onBlur={() => handleRenameCommit(group.rowId)} - onClick={e => e.stopPropagation()} + +
{ if (!isRenaming) onGroupSelect(group); }} + onKeyDown={e => { + if (!isRenaming && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onGroupSelect(group); + } + }} + > + - ) : ( - {group.name} - )} - {/* Rename/delete actions appear only on the active group row */} - {isActive && !isRenaming && group.allowNewGroups && ( - - - - + aria-describedby={renameError ? 'rename-error' : undefined} + aria-invalid={!!renameError} + className={classNames('group-types-panel__rename-input', { + 'group-types-panel__rename-input--error': !!renameError, + })} + value={renameValue} + onChange={e => { setRenameValue(e.target.value); setRenameError(null); }} + onKeyDown={e => { + if (e.key === 'Enter') handleRenameCommit(group.rowId, false); + if (e.key === 'Escape') { setRenamingId(null); setRenameError(null); } + }} + onBlur={() => handleRenameCommit(group.rowId, true)} + onClick={e => e.stopPropagation()} + /> + ) : ( + {group.name} + )} + {/* Rename/delete actions appear only on the active group row */} + {isActive && !isRenaming && group.allowNewGroups && ( + + + + + )} +
+ {isRenaming && renameError && ( +
{renameError}
)} -
+ ); })} {canAdd && ( -
- {/* - * Show a once all - * defaults are consumed or if there are none defined for this type. - */} - {unusedDefaults.length > 0 ? ( - while predefined defaults remain (prevents typos and + * ensures canonical names). Switch to a free-text once all + * defaults are consumed or if there are none defined for this type. + */} + {unusedDefaults.length > 0 ? ( + + ) : ( + setNewGroupName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newGroupName.trim() && !createNameConflicts) handleCreate(); }} + /> + )} + + +
+ {createNameConflicts && ( +
+ A group named "{newGroupName.trim()}" already exists in this type. +
)} - - - + )} {children} diff --git a/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx index 49f03c5e388..6056bd1f949 100644 --- a/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx @@ -6,14 +6,14 @@ import React, { useCallback, useMemo, useRef } from 'react'; import classNames from 'classnames'; -import { PlateTemplate, WellGroup } from '../models'; +import { PlateTemplate, Position, WellGroup } from '../models'; interface Props { plate: PlateTemplate; activeGroup: WellGroup | null; activeTab: string; colorMap: Map; - onCellAssign: (row: number, col: number) => void; + onDragRect: (r1: number, c1: number, r2: number, c2: number, isUnselect: boolean, preDragPositions: Position[]) => void; onCellToggle: (row: number, col: number) => void; } @@ -34,14 +34,14 @@ function getRowLabel(row: number): string { * (no re-renders on drag): * * Phase 1 – mousedown on a cell: - * Enter drag mode. Record the start cell. Do NOT assign it yet — we first need - * to know whether the user is clicking (toggle) or dragging (assign-only). + * Enter drag mode. Record the start cell. Do NOT assign anything yet — we + * first need to know whether the user is clicking (toggle) or dragging (rect). * * Phase 2 – mouseenter a *different* cell while dragging: - * We now know it's a drag. Retroactively assign the original start cell - * (deferred assign), then assign each subsequently entered cell. - * `dragCells` deduplicates entries so fast mouse movement can't assign the - * same cell twice. + * We now know it's a drag. Call onDragRect with the axis-aligned rectangle + * defined by the mousedown cell and the current cell, plus the drag mode + * (select vs unselect) determined at mousedown. The parent replaces or removes + * cells on every call, so the selection dynamically resizes as the mouse moves. * * Phase 3 – mouseup: * If the pointer never left the start cell (hasMoved === false), treat the @@ -50,16 +50,13 @@ function getRowLabel(row: number): string { * * Drag state is also cleaned up on mouseleave of the outer div, preventing stuck * drag state when the pointer exits the grid. - * - * `onCellAssign` is idempotent (ignores duplicates) and also removes the assigned - * cell from any other group of the same type, enforcing the one-group-per-cell-per- - * type constraint. `onCellToggle` does a pure add/remove with no stealing. */ -export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAssign, onCellToggle }: Props): JSX.Element { +export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onDragRect, onCellToggle }: Props): JSX.Element { const isDragging = useRef(false); const hasMoved = useRef(false); const startCell = useRef<{ row: number; col: number } | null>(null); - const dragCells = useRef>(new Set()); + const dragIsUnselect = useRef(false); // true when the drag started on a cell already in the active group + const preDragPositions = useRef([]); // snapshot of activeGroup.positions at mousedown // Pre-compute a "row,col" → {color, groupName} map for the active tab type. // This lets each cell do an O(1) lookup rather than scanning all groups and @@ -81,26 +78,17 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAs isDragging.current = true; hasMoved.current = false; startCell.current = { row, col }; - dragCells.current = new Set([`${row},${col}`]); + dragIsUnselect.current = activeGroup?.positions.some(p => p.row === row && p.col === col) ?? false; + // Snapshot the current positions NOW, from the prop, before any drag events can modify state. + preDragPositions.current = activeGroup?.positions ?? []; e.preventDefault(); - }, []); + }, [activeGroup]); const handleMouseEnter = useCallback((row: number, col: number) => { - if (!isDragging.current) return; - if (!hasMoved.current) { - hasMoved.current = true; - // Deferred assign: now that we know this is a drag, assign the cell - // the user originally pressed down on. - if (startCell.current) { - onCellAssign(startCell.current.row, startCell.current.col); - } - } - const key = `${row},${col}`; - if (!dragCells.current.has(key)) { - dragCells.current.add(key); - onCellAssign(row, col); - } - }, [onCellAssign]); + if (!isDragging.current || !startCell.current) return; + hasMoved.current = true; + onDragRect(startCell.current.row, startCell.current.col, row, col, dragIsUnselect.current, preDragPositions.current); + }, [onDragRect]); // Called on mouseup over a specific cell — handles click-toggle const handleCellMouseUp = useCallback((row: number, col: number) => { @@ -114,7 +102,7 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onCellAs isDragging.current = false; hasMoved.current = false; startCell.current = null; - dragCells.current = new Set(); + dragIsUnselect.current = false; }, []); return ( From 062240583f630f9c20257bd175495b2a32c9a5a7 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sat, 25 Apr 2026 13:14:45 -0700 Subject: [PATCH 4/5] Other cleanup --- .../PlateTemplateDesigner.scss | 1 - .../PlateTemplateDesigner.tsx | 5 +- .../components/GroupTypesPanel.tsx | 5 +- .../components/TemplateGrid.tsx | 61 +++++++++++++++++-- .../client/PlateTemplateDesigner/models.ts | 6 -- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss index 5de546210cf..46162a3fde6 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss @@ -34,7 +34,6 @@ // Root container .plate-template-designer { padding: 12px 16px; - font-family: Arial, Helvetica, sans-serif; font-size: 13px; &__error { diff --git a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx index 8d6ad0ffe8c..d9459e6a23e 100644 --- a/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx @@ -463,6 +463,7 @@ export function PlateTemplateDesigner(): JSX.Element {
Base NameBase Name setMultiBaseName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleMultiCreate(); if (e.key === 'Escape') setMultiCreateOpen(false); }} @@ -242,17 +347,20 @@ export function GroupTypesPanel({
CountCount { setMultiCount(e.target.value); setMultiCountError(''); }} onKeyDown={e => { if (e.key === 'Enter') handleMultiCreate(); if (e.key === 'Escape') setMultiCreateOpen(false); }} /> - {multiCountError &&
{multiCountError}
} + {multiCountError &&
{multiCountError}
}
{Array.from({ length: plate.cols }, (_, col) => ( - {col + 1}{col + 1}
{getRowLabel(row)}{getRowLabel(row)} handleMouseDown(row, col, e)} onMouseEnter={() => handleMouseEnter(row, col)} diff --git a/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx index d297bc1b125..ca49a6ed61c 100644 --- a/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx @@ -3,7 +3,7 @@ * * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { PlateTemplate, computeWarnings } from '../models'; @@ -11,8 +11,16 @@ interface Props { plate: PlateTemplate; } +/** + * Displays the list of validation warnings for the current plate layout. + * + * Warnings are recomputed synchronously from the latest plate state on each render. + * The panel is only shown when `plate.showWarningPanel` is true, which is controlled by the + * server-side assay type configuration (not all assay types use the REPLICATE/SPECIMEN/CONTROL + * group semantics that produce warnings). + */ export function WarningPanel({ plate }: Props): JSX.Element { - const warnings = computeWarnings(plate); + const warnings = useMemo(() => computeWarnings(plate), [plate]); return (
@@ -21,8 +29,8 @@ export function WarningPanel({ plate }: Props): JSX.Element {
No warnings.
) : (
    - {warnings.map((w, i) => ( -
  • {w}
  • + {warnings.map((w) => ( +
  • {w}
  • ))}
)} diff --git a/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx b/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx index e31158345b7..67e242a2d68 100644 --- a/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx +++ b/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.tsx @@ -13,6 +13,20 @@ interface Props { onDeleteProperty: (groupRowId: number, key: string) => void; } +/** + * Shows and edits the key/value property bag for the currently selected well group. + * + * Properties are assay-type-specific metadata attached to a group (e.g. concentration, + * dilution factor, sample ID). They are stored as plain strings and round-tripped through + * the server without interpretation by the designer. + * + * Interaction pattern: + * - Existing properties: each row has an inline text input for the value; changes propagate + * immediately to the parent (no separate submit step) via onPropertyChange. + * - Deleting: the trash button removes a property key entirely. + * - Adding: the footer row accepts a new key + value; "Add" (or Enter) commits the pair. + * The new-key input is the gate — the Add button stays disabled until a key is typed. + */ export function WellGroupProperties({ activeGroup, onPropertyChange, onDeleteProperty }: Props): JSX.Element { const [newKey, setNewKey] = useState(''); const [newValue, setNewValue] = useState(''); @@ -52,6 +66,7 @@ export function WellGroupProperties({ activeGroup, onPropertyChange, onDeletePro onPropertyChange(activeGroup.rowId, key, e.target.value)} /> @@ -60,9 +75,10 @@ export function WellGroupProperties({ activeGroup, onPropertyChange, onDeletePro
([]); // snapshot of activeGroup.positions at mousedown + // Roving-tabindex state: tracks which cell holds tabIndex=0. Null means no cell has been + // focused yet, in which case (0,0) is the tab entry point. + const [focusedCell, setFocusedCell] = useState<{ row: number; col: number } | null>(null); + const cellRefs = useRef>(new Map()); + // Pre-compute a "row,col" → {color, groupName} map for the active tab type. // This lets each cell do an O(1) lookup rather than scanning all groups and // positions on every render (which would be O(groups × positions) per cell). @@ -105,9 +109,44 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onDragRe dragIsUnselect.current = false; }, []); + const handleCellFocus = useCallback((row: number, col: number) => { + setFocusedCell({ row, col }); + }, []); + + // Keyboard interaction for grid cells: + // Space / Enter → toggle the cell (same as a click with no drag) + // Arrow keys → move focus to the adjacent cell (wraps are intentionally prevented + // at plate edges to avoid confusing wrap-around focus jumps) + const handleCellKeyDown = useCallback((row: number, col: number, e: React.KeyboardEvent) => { + const moveFocus = (r: number, c: number) => { + e.preventDefault(); + setFocusedCell({ row: r, col: c }); + cellRefs.current.get(`${r},${c}`)?.focus(); + }; + switch (e.key) { + case ' ': + case 'Enter': + e.preventDefault(); + onCellToggle(row, col); + break; + case 'ArrowUp': + if (row > 0) moveFocus(row - 1, col); + break; + case 'ArrowDown': + if (row < plate.rows - 1) moveFocus(row + 1, col); + break; + case 'ArrowLeft': + if (col > 0) moveFocus(row, col - 1); + break; + case 'ArrowRight': + if (col < plate.cols - 1) moveFocus(row, col + 1); + break; + } + }, [onCellToggle, plate.rows, plate.cols]); + return (
- +
@@ -125,17 +164,29 @@ export function TemplateGrid({ plate, activeGroup, activeTab, colorMap, onDragRe const isActiveGroupCell = activeGroup?.positions.some(p => p.row === row && p.col === col); const location = `${getRowLabel(row)}${col + 1}`; const tooltip = entry ? `${location}: ${entry.groupName}` : location; + const isTabStop = focusedCell + ? focusedCell.row === row && focusedCell.col === col + : row === 0 && col === 0; return ( { + const key = `${row},${col}`; + if (el) cellRefs.current.set(key, el); + else cellRefs.current.delete(key); + }} + tabIndex={isTabStop ? 0 : -1} className={classNames('template-grid__cell', { 'template-grid__cell--active': isActiveGroupCell, })} style={{ backgroundColor: entry?.color ?? '#f5f5f5' }} title={tooltip} + aria-label={tooltip} onMouseDown={e => handleMouseDown(row, col, e)} onMouseEnter={() => handleMouseEnter(row, col)} onMouseUp={() => handleCellMouseUp(row, col)} + onFocus={() => handleCellFocus(row, col)} + onKeyDown={e => handleCellKeyDown(row, col, e)} /> ); })} diff --git a/assay/src/client/PlateTemplateDesigner/models.ts b/assay/src/client/PlateTemplateDesigner/models.ts index 71036b33950..7d8dd6c431f 100644 --- a/assay/src/client/PlateTemplateDesigner/models.ts +++ b/assay/src/client/PlateTemplateDesigner/models.ts @@ -35,13 +35,7 @@ export interface PlateTemplate { defaultPlateName: string; } -export interface SaveTemplateResponse { - rowId: number; -} - /** - * Replicates the GWT TemplateGridCell.getWarnings() logic exactly. - * * Two conditions produce warnings: * 1. A REPLICATE well that belongs to neither a SPECIMEN nor a CONTROL group is almost certainly * a configuration error — replicates are only meaningful relative to a specimen or control. From 19e61e82ae6f321686338ae22f6872841c9d007d Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Sun, 26 Apr 2026 19:08:34 -0700 Subject: [PATCH 5/5] Improve generics for plate code --- .../labkey/api/assay/plate/PlateService.java | 2 +- .../org/labkey/api/assay/plate/PlateSet.java | 2 +- .../src/org/labkey/assay/PlateController.java | 113 ++++++++++-------- .../plate/AssayPlateMetadataServiceImpl.java | 20 ++-- .../org/labkey/assay/plate/PlateCache.java | 24 ++-- .../org/labkey/assay/plate/PlateManager.java | 48 ++++---- .../labkey/assay/plate/PlateManagerTest.java | 60 +++++----- .../org/labkey/assay/plate/PlateSetImpl.java | 2 +- .../assay/plate/layout/LayoutEngine.java | 4 +- .../assay/plate/layout/LayoutOperation.java | 2 +- .../assay/plate/query/PlateSetTable.java | 4 +- 11 files changed, 147 insertions(+), 134 deletions(-) diff --git a/assay/api-src/org/labkey/api/assay/plate/PlateService.java b/assay/api-src/org/labkey/api/assay/plate/PlateService.java index 5021f606c33..b55634aa859 100644 --- a/assay/api-src/org/labkey/api/assay/plate/PlateService.java +++ b/assay/api-src/org/labkey/api/assay/plate/PlateService.java @@ -156,7 +156,7 @@ static PlateService get() */ @Nullable Plate getPlate(ContainerFilter cf, Long plateSetId, Object plateIdentifier); - @NotNull List getPlates(Container container); + @NotNull List getPlates(Container container); /** * Gets the plate set by ID diff --git a/assay/api-src/org/labkey/api/assay/plate/PlateSet.java b/assay/api-src/org/labkey/api/assay/plate/PlateSet.java index 8e06e1c4c77..8dc659afa4d 100644 --- a/assay/api-src/org/labkey/api/assay/plate/PlateSet.java +++ b/assay/api-src/org/labkey/api/assay/plate/PlateSet.java @@ -26,7 +26,7 @@ public interface PlateSet extends Identifiable boolean isTemplate(); - List getPlates(); + List getPlates(); PlateSetType getType(); diff --git a/assay/src/org/labkey/assay/PlateController.java b/assay/src/org/labkey/assay/PlateController.java index d35a2655dc3..dffcee52849 100644 --- a/assay/src/org/labkey/assay/PlateController.java +++ b/assay/src/org/labkey/assay/PlateController.java @@ -112,6 +112,45 @@ public class PlateController extends SpringActionController private static final SpringActionController.DefaultActionResolver _actionResolver = new DefaultActionResolver(PlateController.class); private static final Logger LOG = LogHelper.getLogger(PlateController.class, "Controller for plate related actions"); + record SubmittedGroup(int rowId, String type, String name, List positions, Map properties) + { + public static SubmittedGroup from(JSONObject g) + { + int rowId = g.optInt("rowId", -1); + String type = g.getString("type"); + String name = g.getString("name"); + JSONArray posArr = g.optJSONArray("positions"); + List positions = new ArrayList<>(); + if (posArr != null) + { + for (int j = 0; j < posArr.length(); j++) + { + JSONObject p = posArr.getJSONObject(j); + positions.add(PlatePosition.from(p)); + } + } + JSONObject propsObj = g.optJSONObject("properties"); + Map props = new HashMap<>(); + if (propsObj != null) + { + for (String key : propsObj.keySet()) + { + Object val = propsObj.get(key); + props.put(key, val == JSONObject.NULL ? null : val); + } + } + return new SubmittedGroup(rowId, type, name, positions, props); + } + } + + record PlatePosition(int row, int col) + { + public static PlatePosition from(JSONObject p) + { + return new PlatePosition(p.getInt("row"), p.getInt("col")); + } + } + public PlateController() { setActionResolver(_actionResolver); @@ -165,7 +204,7 @@ public static class PlateListAction extends SimpleViewAction public ModelAndView getView(ReturnUrlForm form, BindException errors) { setHelpTopic("editPlateTemplate"); - List plateTemplates = PlateService.get().getPlates(getContainer()) + List plateTemplates = PlateService.get().getPlates(getContainer()) .stream() .filter(p -> !TsvPlateLayoutHandler.TYPE.equalsIgnoreCase(p.getAssayType())) .toList(); @@ -180,6 +219,7 @@ public void addNavTrail(NavTree root) } } + /** Delete soon! */ @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) public static class DesignerServiceAction extends GWTServiceAction { @@ -417,7 +457,7 @@ public Object execute(SaveTemplateForm form, BindException errors) throws Except } boolean updateExisting = false; - Plate plate; + PlateImpl plate; if (rowId > 0) { plate = PlateManager.get().getPlate(getContainer(), rowId); @@ -447,42 +487,16 @@ public Object execute(SaveTemplateForm form, BindException errors) throws Except plate.setProperties(plateProperties); // Parse groups from JSON - List> submittedGroups = new ArrayList<>(); + List submittedGroups = new ArrayList<>(); Set submittedGroupIds = new HashSet<>(); if (groupsJson != null) { for (int i = 0; i < groupsJson.length(); i++) { - JSONObject g = groupsJson.getJSONObject(i); - Map gm = new HashMap<>(); - gm.put("rowId", g.optInt("rowId", -1)); - gm.put("type", g.getString("type")); - gm.put("name", g.getString("name")); - JSONArray posArr = g.optJSONArray("positions"); - List positions = new ArrayList<>(); - if (posArr != null) - { - for (int j = 0; j < posArr.length(); j++) - { - JSONObject p = posArr.getJSONObject(j); - positions.add(new int[]{p.getInt("row"), p.getInt("col")}); - } - } - gm.put("positions", positions); - JSONObject propsObj = g.optJSONObject("properties"); - Map props = new HashMap<>(); - if (propsObj != null) - { - for (String key : propsObj.keySet()) - { - Object val = propsObj.get(key); - props.put(key, val == JSONObject.NULL ? null : val); - } - } - gm.put("properties", props); - submittedGroups.add(gm); - if ((int) gm.get("rowId") > 0) - submittedGroupIds.add((int) gm.get("rowId")); + SubmittedGroup g = SubmittedGroup.from(groupsJson.getJSONObject(i)); + submittedGroups.add(g); + if (g.rowId > 0) + submittedGroupIds.add(g.rowId); } } @@ -491,14 +505,14 @@ public Object execute(SaveTemplateForm form, BindException errors) throws Except for (WellGroup existingGroup : existingWellGroups) { if (existingGroup.getRowId() != null && !submittedGroupIds.contains(existingGroup.getRowId())) - ((PlateImpl) plate).markWellGroupForDeletion(existingGroup); + plate.markWellGroupForDeletion(existingGroup); } // Update or create well groups - for (Map gm : submittedGroups) + for (SubmittedGroup gm : submittedGroups) { - int gRowId = (int) gm.get("rowId"); - String groupTypeName = (String) gm.get("type"); + int gRowId = gm.rowId(); + String groupTypeName = gm.type(); WellGroup.Type groupType; try { @@ -508,14 +522,12 @@ public Object execute(SaveTemplateForm form, BindException errors) throws Except { throw new ApiUsageException("Unknown well group type: '" + groupTypeName + "'"); } - @SuppressWarnings("unchecked") - List posList = (List) gm.get("positions"); + List posList = gm.positions(); List positions = new ArrayList<>(); - for (int[] p : posList) - positions.add(plate.getPosition(p[0], p[1])); + for (PlatePosition p : posList) + positions.add(plate.getPosition(p.row, p.col)); - @SuppressWarnings("unchecked") - Map props = (Map) gm.get("properties"); + Map props = gm.properties(); WellGroupImpl group; if (updateExisting && gRowId > 0) @@ -524,15 +536,15 @@ public Object execute(SaveTemplateForm form, BindException errors) throws Except if (existing == null) throw new Exception("Well group " + gRowId + " was not found."); if (existing.getType() != groupType) - throw new Exception("Well group type cannot be changed: " + gm.get("name")); - existing.setName((String) gm.get("name")); + throw new Exception("Well group type cannot be changed: " + gm.name()); + existing.setName(gm.name); existing.setPositions(positions); - ((PlateImpl) plate).storeWellGroup(existing); + plate.storeWellGroup(existing); group = existing; } else { - group = (WellGroupImpl) plate.addWellGroup((String) gm.get("name"), groupType, positions); + group = plate.addWellGroup(gm.name, groupType, positions); } group.setProperties(props); } @@ -579,6 +591,7 @@ public void addNavTrail(NavTree root) } } + /** Delete soon! */ @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) public class DesignerGwtAction extends SimpleViewAction { @@ -664,7 +677,7 @@ public static class CopyTemplateBean private HtmlString _treeHtml; private Plate _plate; private String _selectedDestination; - private List _destinationTemplates; + private List _destinationTemplates; public CopyTemplateBean(final Container container, final User user, final Integer plateId, final String selectedDestination) { @@ -1083,7 +1096,7 @@ public Object execute(CreatePlateForm form, BindException errors) throws Excepti PlateImpl newPlate = new PlateImpl(getContainer(), form.getName(), form.getBarcode(), form.getAssayType(), _plateType); if (form.getData() == null && form.getTemplateId() != null && TsvPlateLayoutHandler.TYPE.equalsIgnoreCase(newPlate.getAssayType())) { - newPlate = (PlateImpl) PlateManager.get().copyPlate( + newPlate = PlateManager.get().copyPlate( getContainer(), getUser(), form.getTemplateId(), @@ -1104,7 +1117,7 @@ public Object execute(CreatePlateForm form, BindException errors) throws Excepti if (form.isTemplate() && data == null) data = PlateManager.get().prepareEmptyPlateTemplateData(getContainer(), _plateType); - newPlate = (PlateImpl) PlateManager.get().createAndSavePlate(getContainer(), getUser(), newPlate, form.getPlateSetId(), data); + newPlate = PlateManager.get().createAndSavePlate(getContainer(), getUser(), newPlate, form.getPlateSetId(), data); } return success(newPlate); diff --git a/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java b/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java index b4d7635f972..1dca54939e2 100644 --- a/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java +++ b/assay/src/org/labkey/assay/plate/AssayPlateMetadataServiceImpl.java @@ -238,7 +238,7 @@ public Map apply(Map row) }); } - private List getPlatesForPlateSet( + private List getPlatesForPlateSet( Container container, User user, Long plateSetId, @@ -270,7 +270,7 @@ public DataIteratorBuilder parsePlateData( ) throws ExperimentException { // get the ordered list of plates for the plate set - List plates = getPlatesForPlateSet(container, user, plateSetId, protocol); + List plates = getPlatesForPlateSet(container, user, plateSetId, protocol); if (plates.isEmpty()) throw new ExperimentException("No plates were found for the plate set (" + plateSetId + ")."); PlateSet plateSet = plates.get(0).getPlateSet(); @@ -297,7 +297,7 @@ private List> _parsePlateData( AssayProvider provider, ExpProtocol protocol, PlateSet plateSet, - List plates, + List plates, FileLike dataFile, DataLoaderSettings settings ) throws ExperimentException @@ -356,7 +356,7 @@ public DataIteratorBuilder mergeReRunData( ) throws ExperimentException { Long plateSetId = getPlateSetId(context, provider, protocol); - List plates = getPlatesForPlateSet(container, user, plateSetId, protocol); + List plates = getPlatesForPlateSet(container, user, plateSetId, protocol); if (plates.isEmpty()) throw new ExperimentException("No plates were found for the plate set (" + plateSetId + ")."); @@ -540,7 +540,7 @@ private boolean isGridFormat(List> data) private List> parsePlateRows( AssayProvider provider, ExpProtocol protocol, - List plates, + List plates, List> data ) throws ExperimentException { @@ -604,7 +604,7 @@ private List> parsePlateRows( } // Resolves a pre-calculated "plateIdField" to a plate rowId and furnishes new "data" rows with the plate rowId. - private List> resolvePlateIdentifier(List plates, List> data, String plateIdField) + private List> resolvePlateIdentifier(List plates, List> data, String plateIdField) { var newData = new ArrayList>(); var plateIdentifiers = new HashMap(); @@ -664,7 +664,7 @@ public PlateGridInfo(PlateUtils.GridInfo info, PlateSet plateSet, Set me // locate the plate in the plate set this grid is associated with plus an optional // measure name - List plates = PlateManager.get().getPlatesForPlateSet(plateSet); + List plates = PlateManager.get().getPlatesForPlateSet(plateSet); List annotations = getAnnotations(); // if the plate set only has one plate, then treat a single annotation as the measure @@ -694,7 +694,7 @@ public PlateGridInfo(PlateUtils.GridInfo info, PlateSet plateSet, Set me } } - private @NotNull Plate getPlateForId(String annotation, List platesetPlates) throws ExperimentException + private @NotNull Plate getPlateForId(String annotation, List platesetPlates) throws ExperimentException { Plate plate = platesetPlates.stream().filter(p -> p.isIdentifierMatch(annotation)).findFirst().orElse(null); if (plate == null) @@ -734,7 +734,7 @@ private List> parsePlateGrids( AssayProvider provider, ExpProtocol protocol, PlateSet plateSet, - List plates, + List plates, FileLike dataFile ) throws ExperimentException { @@ -1754,7 +1754,7 @@ public void testGridAnnotations() throws Exception ); PlateSet plateSet = PlateManager.get().createPlateSet(container, user, new PlateSetImpl(), plates, null, null); - List plateSetPlates = PlateManager.get().getPlatesForPlateSet(plateSet); + List plateSetPlates = PlateManager.get().getPlatesForPlateSet(plateSet); assertEquals("Expected two plates to be created.", 2, plateSetPlates.size()); Plate plate = plateSetPlates.get(0); diff --git a/assay/src/org/labkey/assay/plate/PlateCache.java b/assay/src/org/labkey/assay/plate/PlateCache.java index 9827c7cfb5c..828a9674ccf 100644 --- a/assay/src/org/labkey/assay/plate/PlateCache.java +++ b/assay/src/org/labkey/assay/plate/PlateCache.java @@ -32,15 +32,15 @@ public class PlateCache { private static final PlateLoader _loader = new PlateLoader(); - private static final Cache PLATE_CACHE = CacheManager.getBlockingStringKeyCache(CacheManager.UNLIMITED, CacheManager.DAY, "Plate Cache", _loader); + private static final Cache PLATE_CACHE = CacheManager.getBlockingStringKeyCache(CacheManager.UNLIMITED, CacheManager.DAY, "Plate Cache", _loader); private static final Logger LOG = LogManager.getLogger(PlateCache.class); - private static class PlateLoader implements CacheLoader + private static class PlateLoader implements CacheLoader { private final Map> _containerPlateMap = new HashMap<>(); // internal collection to help un-cache all plates for a container @Override - public Plate load(@NotNull String key, @Nullable Object argument) + public PlateImpl load(@NotNull String key, @Nullable Object argument) { // parse the cache key PlateCacheKey cacheKey = new PlateCacheKey(key); @@ -55,7 +55,7 @@ public Plate load(@NotNull String key, @Nullable Object argument) { PlateBean bean = plates.get(0); - Plate plate = PlateManager.get().populatePlate(bean); + PlateImpl plate = PlateManager.get().populatePlate(bean); LOG.debug(String.format("Caching plate \"%s\" for folder %s", plate.getName(), cacheKey._container.getPath())); // add all cache keys for this plate @@ -65,7 +65,7 @@ public Plate load(@NotNull String key, @Nullable Object argument) return null; } - private void addCacheKeys(PlateCacheKey cacheKey, Plate plate) + private void addCacheKeys(PlateCacheKey cacheKey, PlateImpl plate) { if (plate != null) { @@ -84,14 +84,14 @@ private void addCacheKeys(PlateCacheKey cacheKey, Plate plate) if (cacheKey._type != PlateCacheKey.Type.plateId) PLATE_CACHE.put(PlateCacheKey.getCacheKey(plate.getContainer(), plate.getPlateId()), plate); - _containerPlateMap.computeIfAbsent(cacheKey._container, k -> new HashSet<>()).add(plate.getRowId()); + _containerPlateMap.computeIfAbsent(cacheKey._container, _ -> new HashSet<>()).add(plate.getRowId()); } } } - public static @Nullable Plate getPlate(Container c, long rowId) + public static @Nullable PlateImpl getPlate(Container c, long rowId) { - Plate plate = PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, rowId)); + PlateImpl plate = PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, rowId)); // We allow plates to be mutated, return a copy of the cached object which still references the // original wells and well groups return plate != null ? plate.copy() : null; @@ -150,23 +150,23 @@ private void addCacheKeys(PlateCacheKey cacheKey, Plate plate) ).getArrayList(Long.class); } - private static @NotNull List getPlates(Container c, @Nullable SimpleFilter filter) + private static @NotNull List getPlates(Container c, @Nullable SimpleFilter filter) { List ids = getPlateIDs(c, filter); return ids.stream().map(id -> PLATE_CACHE.get(PlateCacheKey.getCacheKey(c, id))).toList(); } - public static @NotNull List getPlates(Container c) + public static @NotNull List getPlates(Container c) { return getPlates(c, null); } - public static @NotNull List getPlatesForPlateSet(Container c, Long plateSetRowId) + public static @NotNull List getPlatesForPlateSet(Container c, Long plateSetRowId) { return getPlates(c, new SimpleFilter(FieldKey.fromParts(PlateTable.Column.PlateSet.name()), plateSetRowId)); } - public static @NotNull List getPlateTemplates(Container c) + public static @NotNull List getPlateTemplates(Container c) { return getPlates(c, new SimpleFilter(FieldKey.fromParts(PlateTable.Column.Template.name()), true)); } diff --git a/assay/src/org/labkey/assay/plate/PlateManager.java b/assay/src/org/labkey/assay/plate/PlateManager.java index 2512cdb4ea9..c12203b1708 100644 --- a/assay/src/org/labkey/assay/plate/PlateManager.java +++ b/assay/src/org/labkey/assay/plate/PlateManager.java @@ -300,15 +300,15 @@ public List getWellGroupTypes() } @Override - public @NotNull Plate createPlate(Container container, String assayType, @NotNull PlateType plateType) + public @NotNull PlateImpl createPlate(Container container, String assayType, @NotNull PlateType plateType) { return new PlateImpl(container, null, null, assayType, plateType); } - public @NotNull Plate createAndSavePlate( + public @NotNull PlateImpl createAndSavePlate( @NotNull Container container, @NotNull User user, - @NotNull Plate plate, + @NotNull PlateImpl plate, @Nullable Long plateSetId, @Nullable List> data ) throws Exception @@ -316,10 +316,10 @@ public List getWellGroupTypes() return createAndSavePlate(container, user, plate, plateSetId, data, false); } - private @NotNull Plate createAndSavePlate( + private @NotNull PlateImpl createAndSavePlate( @NotNull Container container, @NotNull User user, - @NotNull Plate plate, + @NotNull PlateImpl plate, @Nullable Long plateSetId, @Nullable List> data, boolean skipAudit @@ -346,7 +346,7 @@ public List getWellGroupTypes() throw new ValidationException(String.format("Failed to create plate. Plate set \"%s\" is not a template plate set.", plateSet.getName())); if (!plate.isTemplate() && plateSet.isTemplate()) throw new ValidationException(String.format("Failed to create plate. Plate set \"%s\" is a template plate set.", plateSet.getName())); - ((PlateImpl) plate).setPlateSet(plateSet); + plate.setPlateSet(plateSet); } // Intentionally passing skipAudit=true, and not the passed in value for skipAudit, @@ -479,7 +479,7 @@ public Position createPosition(Container container, int row, int column) List plates = new TableSelector(AssayDbSchema.getInstance().getTableInfoPlate(), filter, null).getArrayList(PlateBean.class); // this should be 1 or 0, but don't blow up if there are more than one if (!plates.isEmpty()) - return populatePlate(plates.get(0)); + return populatePlate(plates.getFirst()); return null; } @@ -512,7 +512,7 @@ public List getMetadataColumns(@NotNull PlateSet plateSet, Container c } @NotNull - public List getPlateTemplates(Container container) + public List getPlateTemplates(Container container) { return PlateCache.getPlateTemplates(container); } @@ -557,7 +557,7 @@ public int getRunCountUsingPlate(@NotNull Container c, @NotNull User user, @NotN * @return A map of plate rowId to total number of runs across all plate-based assay runs in the * container/user scope for the specified plates. */ - public Map getPlateRunCounts(@NotNull Container c, @NotNull User user, @NotNull Collection plates) + public Map getPlateRunCounts(@NotNull Container c, @NotNull User user, @NotNull Collection plates) { if (plates.isEmpty()) return emptyMap(); @@ -742,7 +742,7 @@ private int getRunCountUsingPlateInResults(@NotNull Container c, @NotNull User u } @Override - public @Nullable Plate getPlate(Container container, long rowId) + public @Nullable PlateImpl getPlate(Container container, long rowId) { return PlateCache.getPlate(container, rowId); } @@ -786,10 +786,10 @@ private int getRunCountUsingPlateInResults(@NotNull Container c, @NotNull User u Plate plate = null; if (plateIdentifier != null) { - List plates = getPlatesForPlateSet(plateSet); - List matchingPlates = plates.stream().filter(p -> p.isIdentifierMatch(plateIdentifier.toString())).toList(); + List plates = getPlatesForPlateSet(plateSet); + List matchingPlates = plates.stream().filter(p -> p.isIdentifierMatch(plateIdentifier.toString())).toList(); if (matchingPlates.size() == 1) - plate = matchingPlates.get(0); + plate = matchingPlates.getFirst(); else if (matchingPlates.isEmpty()) throw new IllegalArgumentException("The plate identifier \"" + plateIdentifier + "\" does not match any plate in the plate set \"" + plateSet.getName() + "\"."); else @@ -820,7 +820,7 @@ else if (matchingPlates.isEmpty()) throw new IllegalStateException("More than one " + tableInfo.getName() + " found that matches the filter."); if (containers.size() == 1) - return ContainerManager.getForId(containers.get(0)); + return ContainerManager.getForId(containers.getFirst()); return null; } @@ -952,7 +952,7 @@ public boolean isDuplicatePlateTemplateName(Container container, String name) } @Override - public @NotNull List getPlates(Container c) + public @NotNull List getPlates(Container c) { return PlateCache.getPlates(c); } @@ -962,7 +962,7 @@ public boolean isDuplicatePlateTemplateName(Container container, String name) return PlateSetCache.getPlateSets(c); } - public List getPlatesForPlateSet(PlateSet plateSet) + public List getPlatesForPlateSet(PlateSet plateSet) { return PlateCache.getPlatesForPlateSet(plateSet.getContainer(), plateSet.getRowId()); } @@ -1028,7 +1028,7 @@ private long save(Container container, User user, Plate plate, @Nullable List wellGroupIds = wellToWellGroups.computeIfAbsent(wellId, k -> new HashSet<>()); + Set wellGroupIds = wellToWellGroups.computeIfAbsent(wellId, _ -> new HashSet<>()); wellGroupIds.add(wellGroupId); } @@ -1070,7 +1070,7 @@ protected Plate populatePlate(PlateBean bean) { for (Integer wellGroupId : wellGroupIds) { - List groupPositions = groupIdToPositions.computeIfAbsent(wellGroupId, k -> new ArrayList<>()); + List groupPositions = groupIdToPositions.computeIfAbsent(wellGroupId, _ -> new ArrayList<>()); groupPositions.add(well); } } @@ -1274,7 +1274,7 @@ private long savePlateImpl( if (wellDataMap.containsKey(position.getDescription())) { wellDataMap.get(position.getDescription()).forEach( - (key, value) -> wellRow.merge(key, value, (v1, v2) -> v1) + (key, value) -> wellRow.merge(key, value, (v1, _) -> v1) ); } @@ -1956,7 +1956,7 @@ private void copyWellGroups(@NotNull Plate source, @NotNull Plate copy) } } - public Plate copyPlate( + public PlateImpl copyPlate( Container container, User user, Long sourcePlateRowId, @@ -4541,7 +4541,7 @@ public record ReformatResult( Long plateSetRowId; String plateSetName; - List newPlates; + List newPlates; if (targetPlateSet.isNew()) { @@ -4846,7 +4846,7 @@ else if (!Objects.equals(sourcePlateSet.getRowId(), plateSet.getRowId())) return Pair.of(sourcePlateSet, sourcePlates); } - private @NotNull List getReformatTargetPlates(@NotNull PlateSetImpl targetPlateSet) + private @NotNull List getReformatTargetPlates(@NotNull PlateSetImpl targetPlateSet) { if (targetPlateSet.isNew()) return emptyList(); @@ -5079,7 +5079,7 @@ private record HydratedResult(List plateData, @Nullable Integer plate List sourcedWells = Arrays.stream(wellLayout.getWells()).filter(well -> well != null && well.sourcePlateId() > 0).toList(); if (!sourcedWells.isEmpty()) { - Long sourcePlateId = sourcedWells.get(0).sourcePlateId(); + Long sourcePlateId = sourcedWells.getFirst().sourcePlateId(); if (sourcedWells.stream().allMatch(w -> sourcePlateId.equals(w.sourcePlateId()))) templateId = sourcePlateId; } diff --git a/assay/src/org/labkey/assay/plate/PlateManagerTest.java b/assay/src/org/labkey/assay/plate/PlateManagerTest.java index b0df652dbb9..a60e5aacb43 100644 --- a/assay/src/org/labkey/assay/plate/PlateManagerTest.java +++ b/assay/src/org/labkey/assay/plate/PlateManagerTest.java @@ -239,7 +239,7 @@ public void testCreatePlateTemplate() throws Exception List sampleWellGroups = savedTemplate.getWellGroups(WellGroup.Type.SAMPLE); assertEquals(1, sampleWellGroups.size()); - WellGroup savedWg1 = sampleWellGroups.get(0); + WellGroup savedWg1 = sampleWellGroups.getFirst(); assertEquals("wg1", savedWg1.getName()); assertEquals("100", savedWg1.getProperty("score")); @@ -296,7 +296,7 @@ public void testCreatePlateTemplate() throws Exception assertEquals(1, updatedControlWellGroups.size()); // verify added positions - assertEquals(2, updatedControlWellGroups.get(0).getPositions().size()); + assertEquals(2, updatedControlWellGroups.getFirst().getPositions().size()); // verify plate type information assertEquals(plateType.getRows().intValue(), updatedTemplate.getRows()); @@ -353,7 +353,7 @@ public void testAccessPlateByIdentifiers() throws Exception // Assert assertTrue("Expected plateSet to have been persisted and provided with a rowId", plateSet.getRowId() > 0); - List plates = plateSet.getPlates(); + List plates = plateSet.getPlates(); assertEquals("Expected plateSet to have 3 plates", 3, plates.size()); // verify access via plate rowId @@ -394,7 +394,7 @@ public void testCreatePlateTemplates() throws Exception createPlate(PLATE_TYPE_96_WELLS); // Verify only plate templates are returned - List templates = PlateManager.get().getPlateTemplates(container); + List templates = PlateManager.get().getPlateTemplates(container); assertFalse("Expected there to be a plate template", templates.isEmpty()); for (Plate t : templates) assertTrue("Expected saved plate to have the template field set to true", t.isTemplate()); @@ -704,7 +704,7 @@ public void testGetWorklistSingleSampleManyToMany() throws Exception { // Arrange ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(container, user); - ExpMaterial sample = createSamples(1).get(0); + ExpMaterial sample = createSamples(1).getFirst(); List> rows1 = List.of( wellWithMetdata(createWellRow("A1", "SAMPLE", sample.getRowId()), 2.25, "B1234"), @@ -732,7 +732,7 @@ public void testGetWorklistSingleSampleOneToOne() throws Exception { // Arrange ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(container, user); - ExpMaterial sample = createSamples(3).get(0); + ExpMaterial sample = createSamples(3).getFirst(); List> rows1 = List.of( wellWithMetdata(createWellRow("A1", "SAMPLE", sample.getRowId()), 2.25, "B1234"), @@ -769,7 +769,7 @@ public void testGetWorklistSingleSampleOneToMany() throws Exception { // Arrange ContainerFilter cf = ContainerFilter.Type.CurrentAndSubfolders.create(container, user); - ExpMaterial sample = createSamples(3).get(0); + ExpMaterial sample = createSamples(3).getFirst(); List> rows1 = List.of( wellWithMetdata(createWellRow("A1", "SAMPLE", sample.getRowId()), 2.25, "B1234") @@ -927,7 +927,7 @@ public void testReformatQuadrant() throws Exception assertNotNull(result.previewData()); assertEquals("Expected quadrant operation on 3 plates to generate 1 plate.", 1, result.previewData().size()); - var previewPlate = result.previewData().get(0); + var previewPlate = result.previewData().getFirst(); var wellData = previewPlate.data(); assertEquals("Expected 12 wells to have data", 12, wellData.size()); @@ -961,7 +961,7 @@ public void testReformatQuadrant() throws Exception assertTrue("Expected a new plate set to be created", result.plateSetRowId() > 0); assertEquals(1, result.plateRowIds().size()); - var newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + var newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_384_WELLS, newPlate.getPlateType()); @@ -1087,7 +1087,7 @@ public void testReformatCompressByColumn() throws Exception assertNotNull(result.previewData()); assertEquals("Expected column compress operation on a 384-well plate to generate 1 12-well plates.", 1, result.previewData().size()); - List> plateData = result.previewData().get(0).data(); + List> plateData = result.previewData().getFirst().data(); assertEquals("Expected well P12 to be dropped as it does not include a sample.", sourcePlateData.size() - 1, plateData.size()); assertEquals(sampleRowIds.get(0), plateData.get(0).get("sampleId")); @@ -1111,7 +1111,7 @@ public void testReformatCompressByColumn() throws Exception assertEquals("Expected target plate set to be used", targetPlateSetId, result.plateSetRowId()); assertEquals(1, result.plateRowIds().size()); - Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_12_WELLS, newPlate.getPlateType()); @@ -1168,7 +1168,7 @@ public void testReformatCompressByRow() throws Exception assertNotNull(result.previewData()); assertEquals("Expected row compress operation on a 384-well plate to generate 1 12-well plates.", 1, result.previewData().size()); - List> plateData = result.previewData().get(0).data(); + List> plateData = result.previewData().getFirst().data(); assertEquals("Expected well P12 to be dropped as it does not include a sample.", sourcePlateData.size() - 1, plateData.size()); assertEquals(sampleRowIds.get(0), plateData.get(0).get("sampleId")); @@ -1192,7 +1192,7 @@ public void testReformatCompressByRow() throws Exception assertEquals("Expected target plate set to be used", targetPlateSetId, result.plateSetRowId()); assertEquals(1, result.plateRowIds().size()); - Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_12_WELLS, newPlate.getPlateType()); @@ -1302,7 +1302,7 @@ public void testReformatArrayByColumn() throws Exception assertEquals("Expected target plate set to be used", context.targetPlateSetId, result.plateSetRowId()); assertEquals(2, result.plateRowIds().size()); - Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_12_WELLS, newPlate.getPlateType()); List sampleRowIds = context.sampleRowIds; @@ -1368,7 +1368,7 @@ public void testReformatArrayByRow() throws Exception assertEquals("Expected target plate set to be used", context.targetPlateSetId, result.plateSetRowId()); assertEquals(2, result.plateRowIds().size()); - Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_12_WELLS, newPlate.getPlateType()); List sampleRowIds = context.sampleRowIds; @@ -1454,7 +1454,7 @@ public void testReformatArrayFromTemplate() throws Exception assertEquals("Expected target plate set to be used", context.targetPlateSetId, result.plateSetRowId()); assertEquals(3, result.plateRowIds().size()); - Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().get(0)); + Plate newPlate = PlateManager.get().getPlate(container, result.plateRowIds().getFirst()); assertNotNull(newPlate); assertEquals(PLATE_TYPE_12_WELLS, newPlate.getPlateType()); List sampleRowIds = context.sampleRowIds; @@ -1505,7 +1505,7 @@ public void testReformatArrayFromTemplate() throws Exception switch (wellPosition) { - case "A1" -> assertEquals(sampleRowIds.get(0).intValue(), sampleId); // Group "S1" + case "A1" -> assertEquals(sampleRowIds.getFirst().intValue(), sampleId); // Group "S1" case "A2" -> assertEquals(sampleRowIds.get(11).intValue(), sampleId); case "A3" -> assertEquals(sampleRowIds.get(12).intValue(), sampleId); case "A4" -> assertEquals(0, sampleId); @@ -1516,7 +1516,7 @@ public void testReformatArrayFromTemplate() throws Exception case "C1" -> assertEquals(0, sampleId); // Control case "C2" -> assertEquals(0, sampleId); case "C3" -> assertEquals(0, sampleId); // Control - case "C4" -> assertEquals(sampleRowIds.get(0).intValue(), sampleId); // Group "S1" + case "C4" -> assertEquals(sampleRowIds.getFirst().intValue(), sampleId); // Group "S1" } var barcode = r.getString(FieldKey.fromParts(PlateMetadataFields.barcode.name())); @@ -1612,7 +1612,7 @@ public void testReplicateWellValidation() throws Exception assertCreatePlateThrows(expectedMessage, PLATE_TYPE_96_WELLS, plateName, null, sourcePlateData); // Fixup rows by making all rows the same and resubmit - sourcePlateData.forEach(row -> row.put("sampleId", sampleRowIds.get(0))); + sourcePlateData.forEach(row -> row.put("sampleId", sampleRowIds.getFirst())); // Act var newPlate = createPlate(PLATE_TYPE_96_WELLS, plateName, null, sourcePlateData); @@ -1668,8 +1668,8 @@ public void testReplicateCrossPlateValidation() throws Exception List sampleRowIds = createSamples(2).stream().map(ExpObject::getRowId).sorted().toList(); List> plate1Data = new ArrayList<>(); - plate1Data.add(createWellRow("A1", "SAMPLE", sampleRowIds.get(0), null, "R1")); - plate1Data.add(createWellRow("A2", "SAMPLE", sampleRowIds.get(0), null, "R1")); + plate1Data.add(createWellRow("A1", "SAMPLE", sampleRowIds.getFirst(), null, "R1")); + plate1Data.add(createWellRow("A2", "SAMPLE", sampleRowIds.getFirst(), null, "R1")); plate1Data.add(createWellRow("A3", "SAMPLE", sampleRowIds.get(0), null, "R1")); List> plate2Data = new ArrayList<>(); @@ -1679,8 +1679,8 @@ public void testReplicateCrossPlateValidation() throws Exception List> plate3Data = new ArrayList<>(); plate2Data.add(createWellRow("C1", "SAMPLE", sampleRowIds.get(0), null, "R2")); - plate2Data.add(createWellRow("C2", "SAMPLE", sampleRowIds.get(0), null, "R2")); - plate2Data.add(createWellRow("C3", "SAMPLE", sampleRowIds.get(0), null, "R2")); + plate2Data.add(createWellRow("C2", "SAMPLE", sampleRowIds.getFirst(), null, "R2")); + plate2Data.add(createWellRow("C3", "SAMPLE", sampleRowIds.getFirst(), null, "R2")); var plateData = List.of( new PlateManager.PlateData(null, plateType.getRowId(), null, null, plate1Data), @@ -1694,7 +1694,7 @@ public void testReplicateCrossPlateValidation() throws Exception assertCreatePlateSetThrows(expectedMessage, plateSetImpl, plateData, null); // Fixup rows by making all rows the same and resubmit - plate2Data.forEach(row -> row.put("sampleId", sampleRowIds.get(0))); + plate2Data.forEach(row -> row.put("sampleId", sampleRowIds.getFirst())); // Assert (expect no errors) createPlateSet(plateSetImpl, plateData, null); @@ -1721,12 +1721,12 @@ public void testControlValidation() throws Exception var plateData1 = List.of(new PlateManager.PlateData("PS1", plateType.getRowId(), null, null, PS1Data)); PlateSet plateSet1 = createPlateSet(plateSetImpl, plateData1, null); - List> dataPS2 = Arrays.asList(createWellRow("A1", "POSITIVE_CONTROL", sampleRowIds.get(0))); + List> dataPS2 = Arrays.asList(createWellRow("A1", "POSITIVE_CONTROL", sampleRowIds.getFirst())); var plateData2 = List.of(new PlateManager.PlateData("PS2", plateType.getRowId(), null, null, dataPS2)); // Act / Assert // Since the sample of index 0 is on PS1's plate, it is not a valid control for PS2's plate - String errorMsg = String.format("The sample \"%s\" is not a valid control.", sampleNames.get(0)); + String errorMsg = String.format("The sample \"%s\" is not a valid control.", sampleNames.getFirst()); assertCreatePlateSetThrows(errorMsg, plateSetImpl, plateData2, plateSet1.getRowId()); // Assert (expect no errors) @@ -1758,7 +1758,7 @@ public void testBuiltInColumns() throws Exception // Assert assertEquals(1, PPSPlateFields.size()); - assertEquals("SampleID", PPSPlateFields.get(0).getName()); + assertEquals("SampleID", PPSPlateFields.getFirst().getName()); assertEquals(4, APSPlateFields.size()); assertEquals("Type", APSPlateFields.get(0).getName()); @@ -1808,7 +1808,7 @@ public void testEnsureSampleWellTypeTriggerRespectsType() throws Exception List sampleRowIds = samples.stream().map(ExpObject::getRowId).sorted().toList(); List> data = List.of( - createWellRow("A1", "CONTROL", sampleRowIds.get(0)) + createWellRow("A1", "CONTROL", sampleRowIds.getFirst()) ); // Act @@ -1919,12 +1919,12 @@ public void testDeleteSampleWellReferencesUponSampleDelete() throws Exception var plateData = List.of(new PlateManager.PlateData(null, PLATE_TYPE_12_WELLS.getRowId(), null, null, wellData)); var PPS = createPlateSet(pps, plateData, null); - var ppsPlateRowId = PPS.getPlates().get(0).getRowId(); + var ppsPlateRowId = PPS.getPlates().getFirst().getRowId(); var aps = new PlateSetImpl(); aps.setType(PlateSetType.assay); var APS = createPlateSet(aps, plateData, PPS.getRowId()); - var apsPlateRowId = APS.getPlates().get(0).getRowId(); + var apsPlateRowId = APS.getPlates().getFirst().getRowId(); // Act // Formerly, this would result in a foreign key violation on the assay.well table diff --git a/assay/src/org/labkey/assay/plate/PlateSetImpl.java b/assay/src/org/labkey/assay/plate/PlateSetImpl.java index e17b57a09a7..7fe5db23cea 100644 --- a/assay/src/org/labkey/assay/plate/PlateSetImpl.java +++ b/assay/src/org/labkey/assay/plate/PlateSetImpl.java @@ -147,7 +147,7 @@ public boolean isStandalone() } @Override - public List getPlates() + public List getPlates() { if (isNew()) return Collections.emptyList(); diff --git a/assay/src/org/labkey/assay/plate/layout/LayoutEngine.java b/assay/src/org/labkey/assay/plate/layout/LayoutEngine.java index 75bced3f604..96bca37324f 100644 --- a/assay/src/org/labkey/assay/plate/layout/LayoutEngine.java +++ b/assay/src/org/labkey/assay/plate/layout/LayoutEngine.java @@ -19,7 +19,7 @@ public class LayoutEngine private final ReformatOptions _options; private Collection _sampleIds; private List _sourcePlates; - private List _targetPlates; + private List _targetPlates; private List _targetPlateData; private PlateType _targetPlateType; private Plate _targetTemplate; @@ -93,7 +93,7 @@ public void setSourcePlates(List sourcePlates) _sourcePlates = sourcePlates; } - public void setTargetPlates(List targetPlates) + public void setTargetPlates(List targetPlates) { _targetPlates = targetPlates; } diff --git a/assay/src/org/labkey/assay/plate/layout/LayoutOperation.java b/assay/src/org/labkey/assay/plate/layout/LayoutOperation.java index 2809df13514..cef436ec356 100644 --- a/assay/src/org/labkey/assay/plate/layout/LayoutOperation.java +++ b/assay/src/org/labkey/assay/plate/layout/LayoutOperation.java @@ -59,7 +59,7 @@ record ExecutionContext( PlateType targetPlateType, List sourcePlates, Plate targetTemplate, - List targetPlates, + List targetPlates, List targetPlateData, Collection sampleIds, WellData.Cache wellDataCache diff --git a/assay/src/org/labkey/assay/plate/query/PlateSetTable.java b/assay/src/org/labkey/assay/plate/query/PlateSetTable.java index dc5cd4927d7..42499ff0ab4 100644 --- a/assay/src/org/labkey/assay/plate/query/PlateSetTable.java +++ b/assay/src/org/labkey/assay/plate/query/PlateSetTable.java @@ -194,7 +194,7 @@ public DataIteratorBuilder createImportDIB(User user, Container container, DataI // generate a value for the lsid final TableInfo plateSetTable = getQueryTable(); lsidGenerator.addColumn(plateSetTable.getColumn(PlateTable.Column.Lsid.name()), - (Supplier) () -> PlateManager.get().getLsid(PlateSet.class, container)); + (Supplier) () -> PlateManager.get().getLsid(PlateSet.class, container)); SimpleTranslator nameExpressionTranslator = new SimpleTranslator(lsidGenerator, context); nameExpressionTranslator.setDebugName("nameExpressionTranslator"); @@ -252,7 +252,7 @@ protected Map deleteRow( if (plateSet == null) throw new QueryUpdateServiceException(String.format("Plate set could not be found for ID : %d", rowId)); - List plates = plateSet.getPlates(); + List plates = plateSet.getPlates(); if (!plates.isEmpty()) throw new QueryUpdateServiceException(String.format("Plate set has %d plates associated with it and cannot be deleted.", plates.size()));