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/client/PlateTemplateDesigner/PlateTemplateDesigner.scss b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss new file mode 100644 index 00000000000..46162a3fde6 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.scss @@ -0,0 +1,682 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * 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-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; + } + + // 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 ───────────────────────────────────────────────────────────────── +// Pinned above the plate header; uses flex to space buttons and status text. +.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; + } + } + } + + // Unsaved-changes indicator — uses role="status" in JSX for screen reader announcements + &__dirty { + color: #7a5800; + font-style: italic; + } + + // Transient save status ("Saving…", "Saved.") — auto-clears after 5 s + &__status { + color: #555; + } +} + +// ── 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; + + &__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; + } + } + + // Flex row: [group list] [grid area] — keeps the grid visually docked to the panel + &__tab-body { + display: flex; + align-items: flex-start; + gap: 12px; + 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; + 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; + } + } + + // Colour indicator matching the group's colour on the grid + &__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; + } + + // In-place rename input (replaces group-name span when renaming is active) + &__rename-input { + flex: 1; + padding: 1px 4px; + border: 1px solid #337ab7; + 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 + &__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; + } + } + } + + // Row that holds the new-group name input + Create / Create multiple buttons + &__create-row { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; + } + + // Shared by both the (free-text) variants + &__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 ─────────────────────────────────────────────────────── +// 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; + 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 cells carry id attributes and are referenced via aria-labelledby on the inputs + &__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; + } +} + +// ── Plate grid area ─────────────────────────────────────────────────────────── +// Centres the TemplateGrid and ShiftPanel as a vertical column inside GroupTypesPanel. +.plate-grid-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +// ── 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; + 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 ────────────────────────────────────────────────────────────── +// 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; // Prevents text selection during drag painting + + &__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); + } + + // Indicates cells belonging to the currently active group + &--active { + outline: 2px solid #333; + outline-offset: -2px; + } + } +} + +// ── 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; + 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: #7a5800; + } + + &--active#{&}--warn { + border-bottom-color: #7a5800; + } + } +} + +// ── 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; + padding: 10px; + min-height: 60px; + + &--empty { + color: #767676; + font-style: italic; + } + + &__title { + font-weight: bold; + margin-bottom: 8px; + } + + &__no-props { + color: #767676; + font-style: italic; + font-size: 12px; + } + + &__table { + width: 100%; + border-collapse: collapse; + } + + // Key column — fixed at 40% width, non-wrapping + &__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%; // Shrink-wraps to button content + } + + &__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; + } + } + + // Footer row — Add new property; separated visually from existing entries + &__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 ────────────────────────────────────────────────────────────── +// 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; + padding: 10px; + background: #fffbe6; + + &__title { + font-weight: bold; + color: #7a5800; + margin-bottom: 6px; + } + + &__none { + color: #767676; + 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..d9459e6a23e --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/PlateTemplateDesigner.tsx @@ -0,0 +1,512 @@ +/* + * 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 classNames from 'classnames'; +import { ActionURL, Ajax, Utils } from '@labkey/api'; + +import { PlateTemplate, Position, 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'; + +/** + * 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', + '#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(''); // 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'); + 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)); + 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); + }), + 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); + }, []); + + // 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) { + 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 (!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); + }, []); + + // Pure toggle: add the cell if absent, remove it if present + const handleCellToggle = useCallback((row: number, col: number) => { + const activeGroup = activeGroupRef.current; + if (!activeGroup) return; + setPlate(prev => { + if (!prev) return null; + const updatedGroups = prev.groups.map(g => { + 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, positions: [...g.positions, { row, col }] }; + }); + return { ...prev, groups: updatedGroups }; + }); + setIsDirty(true); + }, []); + + 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); + const colorIndex = nextColorIndexRef.current++; + setColorMap(prev => { + const next = new Map(prev); + next.set(rowId, COLORS[colorIndex % 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'); + }, []); + + /** + * 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: currentPlate, + success: Utils.getCallbackWrapper(onSuccess), + failure: Utils.getCallbackWrapper((response: any) => { + setStatus('Save failed: ' + (response?.exception ?? 'unknown error')); + }, null, true), + }); + }, []); + + 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; + if (!isDirty) { + navigateAway(); + return; + } + requestSave(plate, () => { + setIsDirty(false); + navigateAway(); + }); + }, [plate, isDirty, navigateAway, requestSave]); + + 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. + // + // 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 (!plate) return; + if (activeGroup) { + 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} + > + {/* 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/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..986d5b74d1c --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/GroupTypesPanel.tsx @@ -0,0 +1,429 @@ +/* + * 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 classNames from 'classnames'; + +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; +} + +/** + * 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, + 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 [renameError, setRenameError] = useState(null); + const [multiCreateOpen, setMultiCreateOpen] = useState(false); + const [multiBaseName, setMultiBaseName] = useState(''); + const [multiCount, setMultiCount] = useState('2'); + const [multiCountError, setMultiCountError] = useState(''); + const dialogRef = useRef(null); + + // 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; + + // 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(() => { + 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 + + // 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 || createNameConflicts) return; + onAddGroup(activeTab, trimmed); + setNewGroupName(''); + }; + + const openMultiCreate = () => { + setMultiBaseName(newGroupName.trim()); + setMultiCount('2'); + setMultiCountError(''); + setMultiCreateOpen(true); + // Focus is handled by the focus-trap effect above + }; + + 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; + 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); + }; + + 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); + setRenameError(null); + }; + + // 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 ( +
+
+ {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); }} + onKeyDown={e => { + if (!isRenaming && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onGroupSelect(group); + } + }} + > +
+ {isRenaming && renameError && ( +
{renameError}
+ )} +
+ ); + })} + {canAdd && ( + <> +
+ {/* + * Show a 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} +
+ {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..43c3d3cb2a0 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/ShiftPanel.tsx @@ -0,0 +1,42 @@ +/* + * 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; +} + +/** + * 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 new file mode 100644 index 00000000000..9afc2600133 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/StatusBar.tsx @@ -0,0 +1,47 @@ +/* + * 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; +} + +/** + * 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 ( +
+ + + + {isDirty ? 'Unsaved changes' : ''} + {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..67600997651 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/TemplateGrid.tsx @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; + +import { PlateTemplate, Position, WellGroup } from '../models'; + +interface Props { + plate: PlateTemplate; + activeGroup: WellGroup | null; + activeTab: string; + colorMap: Map; + onDragRect: (r1: number, c1: number, r2: number, c2: number, isUnselect: boolean, preDragPositions: Position[]) => void; + onCellToggle: (row: number, col: number) => void; +} + +function getRowLabel(row: number): string { + return String.fromCharCode(65 + row); +} + +/** + * 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 colored. Wells from + * other types are invisible in the current view. + * + * ─── 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 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. 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 + * 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. + */ +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 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 + + // 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). + 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; + hasMoved.current = false; + startCell.current = { 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 || !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) => { + 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; + 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 ( +
+ + + + + ))} + + + + {Array.from({ length: plate.rows }, (_, row) => ( + + + {Array.from({ length: plate.cols }, (_, col) => { + 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 tooltip = entry ? `${location}: ${entry.groupName}` : location; + const isTabStop = focusedCell + ? focusedCell.row === row && focusedCell.col === col + : row === 0 && col === 0; + return ( + + ))} + +
+ {Array.from({ length: plate.cols }, (_, col) => ( + {col + 1}
{getRowLabel(row)} { + 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/components/WarningPanel.tsx b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx new file mode 100644 index 00000000000..ca49a6ed61c --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/WarningPanel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +import React, { useMemo } from 'react'; + +import { PlateTemplate, computeWarnings } from '../models'; + +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 = useMemo(() => computeWarnings(plate), [plate]); + + return ( +
+
Warnings
+ {warnings.length === 0 ? ( +
No warnings.
+ ) : ( +
    + {warnings.map((w) => ( +
  • {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..67e242a2d68 --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/components/WellGroupProperties.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, { 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; +} + +/** + * 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(''); + + 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..7d8dd6c431f --- /dev/null +++ b/assay/src/client/PlateTemplateDesigner/models.ts @@ -0,0 +1,75 @@ +/* + * 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; // 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; // Whether the user can create/rename/delete groups of this type +} + +export interface PlateTemplate { + rowId: number; + name: string; + type: string; + rows: number; + cols: number; + 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; // 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; // True when the plate was loaded as a copy; starts the editor in dirty state + defaultPlateName: string; +} + +/** + * 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) { + 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..dffcee52849 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; @@ -101,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); @@ -122,7 +172,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) @@ -154,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(); @@ -169,6 +219,7 @@ public void addNavTrail(NavTree root) } } + /** Delete soon! */ @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) public static class DesignerServiceAction extends GWTServiceAction { @@ -211,7 +262,338 @@ 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; + PlateImpl 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++) + { + SubmittedGroup g = SubmittedGroup.from(groupsJson.getJSONObject(i)); + submittedGroups.add(g); + if (g.rowId > 0) + submittedGroupIds.add(g.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())) + plate.markWellGroupForDeletion(existingGroup); + } + + // Update or create well groups + for (SubmittedGroup gm : submittedGroups) + { + int gRowId = gm.rowId(); + String groupTypeName = gm.type(); + WellGroup.Type groupType; + try + { + groupType = WellGroup.Type.valueOf(groupTypeName); + } + catch (IllegalArgumentException e) + { + throw new ApiUsageException("Unknown well group type: '" + groupTypeName + "'"); + } + List posList = gm.positions(); + List positions = new ArrayList<>(); + for (PlatePosition p : posList) + positions.add(plate.getPosition(p.row, p.col)); + + Map props = gm.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.name()); + existing.setName(gm.name); + existing.setPositions(positions); + plate.storeWellGroup(existing); + group = existing; + } + else + { + group = plate.addWellGroup(gm.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"); + } + } + + /** Delete soon! */ + @RequiresAnyOf({InsertPermission.class, DesignAssayPermission.class}) + public class DesignerGwtAction extends SimpleViewAction { @Override public ModelAndView getView(DesignerForm form, BindException errors) @@ -262,7 +644,7 @@ else if (form.getPlateId() != null) public void addNavTrail(NavTree root) { setHelpTopic("editPlateTemplate"); - root.addChild("Plate Editor"); + root.addChild("Plate Editor (GWT)"); } } @@ -295,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) { @@ -714,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(), @@ -735,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/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); 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()));