From 6ef3e685a3ce91c2f6338acdac464f1f1f3bb4ee Mon Sep 17 00:00:00 2001 From: LeviCameron1 <86750204+LeviCameron1@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:36:08 -0500 Subject: [PATCH 1/5] Bug fix for creating new racks on top of previous real racks (#1001) ## Rationale Users are currently unable to create a new rack on a rack that is currently real as well due to conflicting object ids. This patch ensures that new object ids are generated if a rack is created on an existing rack. ## Related Pull Requests - ## Changes - Update the change rack function to generate new object ids when creating new racks --- .../context/LayoutEditorContextManager.tsx | 136 ++++++++++-------- 1 file changed, 75 insertions(+), 61 deletions(-) diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index aaf6b90bf..e7e278292 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -1017,7 +1017,9 @@ export const LayoutEditorContextProvider: FC = ({children, p }; const changeRack = async (newType: RackChangeOption): Promise => { - let {value: rackChangeValue, label: rackLabel} = newType; + const { value: rackChangeValue } = newType; + const originalSelectedCage = selectedObj as Cage; + const selectedCagePositionId = originalSelectedCage.positionId; let prevCages: CageData[] = []; if (!rackChangeValue.isNew) { @@ -1029,72 +1031,84 @@ export const LayoutEditorContextProvider: FC = ({children, p ] }; const cageDataRes = await labkeyActionSelectWithPromise(cagesInRackConfig); - prevCages = cageDataRes.rows.map(r => ({ - ...r, - positionId: r.positionid, - objectId: r.objectid, - })); + if(cageDataRes.rowCount !== 0){ + prevCages = cageDataRes.rows.map(r => ({ + ...r, + positionId: r.positionid, + objectId: r.objectid, + })); + } } - setLocalRoom(prevRoom => { - const { - rackGroup, - rack, - cage - } = findCageInGroup((selectedObj as Cage).svgId as CageSvgId, prevRoom.rackGroups); - const roomToUpdate: Room = { - ...prevRoom, - rackGroups: prevRoom.rackGroups.map(group => - group.groupId === rackGroup.groupId - ? { - ...group, - racks: group.racks.map((r) => r.objectId === rack.objectId ? { - ...r, - itemId: rackChangeValue.rackId, - objectId: rackChangeValue.rackObjectId, - svgId: `rack_${rackChangeValue.rackObjectId}`, - isNew: rackChangeValue.isNew, - type: { - ...r.type, - rowid: rackChangeValue.rackType.rowid, - displayName: rackChangeValue.rackType.displayName, - type: rackChangeValue.rackType.type, - isDefault: rackChangeValue.rackType.isDefault - }, - cages: prevCages.length > 0 ? r.cages.map((c) => { - const prevCage = prevCages.find(pc => pc.positionId === c.positionId); - const key = roomItemToString(rackChangeValue.rackType.type); - const newSvgId = `cageSVG_${prevCage.objectId}`; - setUnitLocs((prevState) => ({ - ...prevState, - [key]: prevState[key].map((loc) => { - if (loc.cageId === c.svgId) { - return { - ...loc, - cageId: newSvgId - }; - } - return loc; - }) - })); - return { - ...c, - objectId: prevCage.objectId, - svgId: newSvgId, - positionId: prevCage.positionId, - }; - }) : r.cages - } as Rack : r) - } - : group - ) - }; - setReloadRoom(roomToUpdate); - return roomToUpdate; + const { rackGroup, rack } = findCageInGroup(originalSelectedCage.svgId, localRoom.rackGroups); + const newUnitLocs = { ...unitLocs }; + let newSelectedCage: Cage | undefined; + + const updatedCages = rack.cages.map(c => { + const key = roomItemToString(rackChangeValue.rackType.type); + let newCageData: { objectId: string, svgId: CageSvgId }; + + if (rackChangeValue.isNew) { + const newObjId = generateUUID(); + newCageData = { objectId: newObjId, svgId: `cageSVG_${newObjId}` as CageSvgId }; + } else { + const prevCage = prevCages.find(pc => pc.positionId === c.positionId); + newCageData = { objectId: prevCage.objectId, svgId: `cageSVG_${prevCage.objectId}` as CageSvgId }; + } + + const locIndex = newUnitLocs[key]?.findIndex(loc => loc.cageId === c.svgId); + if (locIndex > -1) { + newUnitLocs[key][locIndex] = { ...newUnitLocs[key][locIndex], cageId: newCageData.svgId }; + } + + const newCage: Cage = { ...c, ...newCageData }; + if (c.positionId === selectedCagePositionId) { + newSelectedCage = newCage; + } + return newCage; }); + + const roomToUpdate: Room = { + ...localRoom, + rackGroups: localRoom.rackGroups.map(group => + group.groupId === rackGroup.groupId + ? { + ...group, + racks: group.racks.map(r => + r.objectId === rack.objectId + ? { + ...r, + itemId: rackChangeValue.rackId, + objectId: rackChangeValue.rackObjectId, + svgId: `rack_${rackChangeValue.rackObjectId}`, + isNew: rackChangeValue.isNew, + type: { + ...r.type, + rowid: rackChangeValue.rackType.rowid, + displayName: rackChangeValue.rackType.displayName, + type: rackChangeValue.rackType.type, + isDefault: rackChangeValue.rackType.isDefault, + }, + cages: updatedCages, + } + : r + ), + } + : group + ), + }; + + setUnitLocs(newUnitLocs); + setLocalRoom(roomToUpdate); + if (newSelectedCage) { + setSelectedObj(newSelectedCage); + } + setReloadRoom(roomToUpdate); + return `rack_${rackChangeValue.rackObjectId}`; }; + const changeCageNum = (numBefore: number, numAfter: number) => { const selectedCage = (selectedObj as Cage); From 70cbf00ab42b5df9fc7648c4adcfa0bd6390a046 Mon Sep 17 00:00:00 2001 From: LeviCameron1 <86750204+LeviCameron1@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:18:46 -0500 Subject: [PATCH 2/5] CageuUI ghost cages functionality (#1012) ## Rationale Added ghost cage functionality for rack assignment to the rooms in the cageUI project. ## Related Pull Requests ## Changes - New sql script to add ghost cage table - Code to handle ghost cage assignment and renumbering. - Species check for how to handle renumbering on rooms - Will need to add rack type for ghost cage once this gets put on production. --- .../queries/cageui/ghost_cages.query.xml | 43 +++++++ .../queries/cageui/layout_history.query.xml | 9 +- CageUI/resources/schemas/cageui.xml | 16 +++ .../postgresql/cageui-26.001-26.002.sql | 42 +++++++ CageUI/src/client/api/labkeyActions.ts | 2 +- CageUI/src/client/api/popularQueries.ts | 31 ++++- .../src/client/components/home/RoomList.tsx | 2 +- .../home/rackView/ChangeRackPopup.tsx | 13 ++- .../context/LayoutEditorContextManager.tsx | 2 + .../pages/layoutEditor/LayoutEditor.tsx | 2 + CageUI/src/client/types/typings.ts | 20 +++- .../src/client/utils/LayoutEditorHelpers.ts | 25 +++- CageUI/src/client/utils/helpers.ts | 77 +++++++++---- .../org/labkey/cageui/CageUIController.java | 6 +- .../src/org/labkey/cageui/CageUIManager.java | 86 +++++++++++++- .../src/org/labkey/cageui/CageUIModule.java | 2 +- .../labkey/cageui/action/BundledForms.java | 12 ++ .../labkey/cageui/action/GhostCagesForm.java | 107 ++++++++++++++++++ .../org/labkey/cageui/model/RackTypes.java | 34 +++++- CageUI/src/org/labkey/cageui/model/Room.java | 11 ++ .../domain-templates/ehr_lookups.template.xml | 37 ++++++ .../queries/ehr_lookups/rooms.query.xml | 41 +++++++ 22 files changed, 572 insertions(+), 48 deletions(-) create mode 100644 CageUI/resources/queries/cageui/ghost_cages.query.xml create mode 100644 CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql create mode 100644 CageUI/src/org/labkey/cageui/action/GhostCagesForm.java create mode 100644 WNPRC_EHR/resources/domain-templates/ehr_lookups.template.xml create mode 100644 WNPRC_EHR/resources/queries/ehr_lookups/rooms.query.xml diff --git a/CageUI/resources/queries/cageui/ghost_cages.query.xml b/CageUI/resources/queries/cageui/ghost_cages.query.xml new file mode 100644 index 000000000..59fde7a12 --- /dev/null +++ b/CageUI/resources/queries/cageui/ghost_cages.query.xml @@ -0,0 +1,43 @@ + + + + + + + Ghost Cages + + + true + + + + + + + + + + + + + +
+
+
+
\ No newline at end of file diff --git a/CageUI/resources/queries/cageui/layout_history.query.xml b/CageUI/resources/queries/cageui/layout_history.query.xml index 5935123b8..2cda5414c 100644 --- a/CageUI/resources/queries/cageui/layout_history.query.xml +++ b/CageUI/resources/queries/cageui/layout_history.query.xml @@ -26,14 +26,7 @@ true - - - cageui - cages - objectid - cage_number - - + integer diff --git a/CageUI/resources/schemas/cageui.xml b/CageUI/resources/schemas/cageui.xml index 059cb91be..973cf7c5a 100644 --- a/CageUI/resources/schemas/cageui.xml +++ b/CageUI/resources/schemas/cageui.xml @@ -198,4 +198,20 @@ + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql new file mode 100644 index 000000000..d20ac7610 --- /dev/null +++ b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql @@ -0,0 +1,42 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +DROP TABLE IF EXISTS cageui.ghost_cages; +CREATE TABLE cageui.ghost_cages +( + rowid SERIAL NOT NULL, + cage_objectid VARCHAR NOT NULL, + positionid INTEGER, + rack_group INTEGER NOT NULL, + rack_objectid VARCHAR NOT NULL, + group_rotation INTEGER NOT NULL, + cage INTEGER NOT NULL, + container entityid NOT NULL, + createdby userid, + created TIMESTAMP, + modifiedby userid, + modified TIMESTAMP, + CONSTRAINT PK_ghost_cages PRIMARY KEY (rowid), + CONSTRAINT FK_ghost_cages_container FOREIGN KEY (container) REFERENCES core.Containers (EntityId) +); + +insert into ehr_lookups.lookups (set_name,container,value, category, title, description) +select setname, container, 8 as value, 'Caging' as category, 'Ghost Cage' as title, 4 as description from ehr_lookups.lookup_sets where setname='cageui_item_types'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'ghostCage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; diff --git a/CageUI/src/client/api/labkeyActions.ts b/CageUI/src/client/api/labkeyActions.ts index 27aa3fda0..7a9451d59 100644 --- a/CageUI/src/client/api/labkeyActions.ts +++ b/CageUI/src/client/api/labkeyActions.ts @@ -165,7 +165,7 @@ export function saveRoomLayout(room: Room, mods: CageMods[], prevRoomName: strin export function createNewRoomFromRackChange(room: Room, newRackOption: RackSwitchOption, prevRack: Rack ): Promise<{ room: Room, - rack: string; + rack: string, errors: any[] }> { return new Promise((resolve, reject) => { diff --git a/CageUI/src/client/api/popularQueries.ts b/CageUI/src/client/api/popularQueries.ts index 471875613..a1fb5e891 100644 --- a/CageUI/src/client/api/popularQueries.ts +++ b/CageUI/src/client/api/popularQueries.ts @@ -18,7 +18,7 @@ import { Filter, Query } from '@labkey/api'; import { labkeyActionSelectWithPromise } from './labkeyActions'; import { EHRCageMods } from '../types/homeTypes'; -import { CageData, CageHistoryData, RackData } from '../types/typings'; +import { CageData, CageHistoryData, GhostCageData, RackData } from '../types/typings'; export const cageModLookup = async (columns: string[], filterArray: Filter.IFilter[]): Promise => { const config: Query.SelectRowsOptions = { @@ -100,6 +100,35 @@ export const fetchCage = async (objectId: string): Promise => { } }; +export const fetchGhostCage = async (objectId: string): Promise => { + const config: Query.SelectRowsOptions = { + schemaName: 'cageui', + queryName: 'ghost_cages', + filterArray: [Filter.create('cage_objectid', objectId, Filter.Types.EQUAL)] + }; + + try { + const res = await labkeyActionSelectWithPromise(config); + if (res.rows.length === 1) { + return { + rowid: res.rows[0].rowid, + cageObjId: res.rows[0].cage_objectid, + positionId: res.rows[0].positionid, + rackGroup: res.rows[0].rack_group, + rack: 0, + rackObjId: res.rows[0].rack_objectid, + groupRotation: res.rows[0].group_rotation, + cage: res.rows[0].cage, + }; + } else { + throw new Error('Error fetching ghost cage data'); + } + } + catch (e) { + throw new Error('Error fetching ghost cage data: ' + (e as Error).message); + } +}; + export const fetchRack = async (objectId: string): Promise => { const config: Query.SelectRowsOptions = { schemaName: 'cageui', diff --git a/CageUI/src/client/components/home/RoomList.tsx b/CageUI/src/client/components/home/RoomList.tsx index 98bbed5f9..d3bcc7618 100644 --- a/CageUI/src/client/components/home/RoomList.tsx +++ b/CageUI/src/client/components/home/RoomList.tsx @@ -112,7 +112,7 @@ export const RoomList: FC = () => { } else { tempRacks.push({ id: r.svgId, - name: `Rack-${r.itemId}`, + name: r.itemId === 0 ? 'Ghost Rack' : `Rack-${r.itemId}`, cages: [{ name: c.cageNum, id: c.svgId diff --git a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx index 9dbe8536f..72bd22f0e 100644 --- a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx +++ b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx @@ -28,6 +28,7 @@ import { RackSwitchOption } from '../../../types/homeTypes'; import { LayoutErrors } from '../../LayoutErrors'; import { LoadingScreen } from '../../LoadingScreen'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { generateUUID } from '../../../utils/helpers'; interface ChangeRackPopupProps { showChangeRackPopup: React.Dispatch>; @@ -89,7 +90,7 @@ export const ChangeRackPopup: FC = (props) => { }; labkeyActionSelectWithPromise(racksConfig).then((racksResult) => { if (racksResult.rowCount > 0) { - const options = racksResult.rows.reduce((acc, row) => { + let options = racksResult.rows.reduce((acc, row) => { acc.push({ value: { objectId: row.objectid, @@ -100,6 +101,15 @@ export const ChangeRackPopup: FC = (props) => { }); return acc; }, [] as RackSwitchOption[]); + const ghostCageOption: RackSwitchOption = { + value: { + objectId: generateUUID(), + rackId: 0, + typeRowId: 0 + }, + label: "Ghost Rack" + } + options = [ghostCageOption, ...options]; setRackOptions(options); } }); @@ -149,7 +159,6 @@ export const ChangeRackPopup: FC = (props) => { 'home', ActionURL.getContainer(), {room: res.roomName, rack: res.rack}); - } else { setIsSaving(false); if (res?.reason) { diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index e7e278292..a94814ab3 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -102,6 +102,7 @@ export const LayoutEditorContextProvider: FC = ({children, p }); // loaded in and unchanged since start of layout editing const [room, setRoom] = useState({ + species: '', name: 'new-layout', rackGroups: [], valid: false, @@ -118,6 +119,7 @@ export const LayoutEditorContextProvider: FC = ({children, p // All changes made to room reflect here. Use room state to compare to the start of room editing vs the changes made here const [localRoom, setLocalRoom] = useState({ name: 'new-layout', + species: '', rackGroups: [], valid: false, objects: [], diff --git a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx index 06cc07517..d6fc8cf38 100644 --- a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx +++ b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx @@ -35,6 +35,7 @@ export const LayoutEditor: FC = () => { const roomName: string = ActionURL.getParameter('room'); const [prevRoomData, setPrevRoomData] = useState({ name: null, + species: '', cagingData: [], layoutData: null, isDefault: true @@ -127,6 +128,7 @@ export const LayoutEditor: FC = () => { // Don't use template name instead treat the template as an empty room with objects already placed newLocalRoom = { name: isTemplate ? 'new-layout' : prevRoomData.name, + species: '', rackGroups: [], valid: false, objects: [], diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index 18076e486..5ec6afb9a 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -45,7 +45,8 @@ export enum RackTypes { Cage = 4, Pen = 5, TempCage = 6, - PlayCage = 7 + PlayCage = 7, + GhostCage = 8 } // Like rack types enum but for room objects, start at 100 to give buffer room for rack types @@ -235,6 +236,7 @@ export interface CageModificationsType { export interface Room { name: string; + species: string; valid: boolean; rackGroups: RackGroup[]; objects: RoomObject[]; @@ -242,6 +244,18 @@ export interface Room { mods?: RoomMods; } + +export interface GhostCageData { + rowid: number; + cageObjId: string; + positionId: number; + rackGroup: number; + rack: number; + rackObjId: string; + groupRotation: number; + cage: number; +} + export interface LayoutData { scale: number; borderWidth: number; @@ -320,13 +334,14 @@ export interface AllHistoryData { } export interface FullObjectHistoryData { + isGhost: boolean; objectType: RoomObjectTypes | RackTypes | DefaultRackTypes; extraContext: string | null; rackGroup?: number; groupRotation?: GroupRotation; // objectid of rack in racks table rack?: RackData | number; - cage?: FullCageHistory | number; + cage?: FullCageHistory | GhostCageData | number; xCoord: number; yCoord: number; } @@ -346,6 +361,7 @@ export interface PrevRoom { layoutData: LayoutData; modData?: ModData[]; isDefault: boolean; + species: string; name: string | null; } diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index d83e9d329..492e79b8b 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -23,7 +23,10 @@ import { generateUUID, getAdjLocation, getDefaultMod, - getTypeClassFromElement, isRoomCreator, isRoomModifier, isTemplateCreator, + getTypeClassFromElement, + isRoomCreator, + isRoomModifier, + isTemplateCreator, parseRoomItemType, roomItemToString } from './helpers'; @@ -35,7 +38,7 @@ import { CageMods, CageSvgId, DefaultRackTypes, - FullObjectHistoryData, + FullObjectHistoryData, GhostCageData, GroupId, LayoutHistoryData, LocationCoords, @@ -64,12 +67,10 @@ import * as React from 'react'; import { MutableRefObject } from 'react'; import { Security } from '@labkey/api'; import { CELL_SIZE } from './constants'; -import { fetchCage, fetchCageHistory, fetchRack } from '../api/popularQueries'; +import { fetchCage, fetchCageHistory, fetchGhostCage, fetchRack } from '../api/popularQueries'; import { ConnectedCage, ConnectedRack } from '../types/homeTypes'; - - export const isTouchEvent = (event)=> { return event.type.startsWith('touch'); } @@ -136,16 +137,30 @@ export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promi const processItem = async (item: LayoutHistoryData): Promise => { if (item.cage === null) { return { + isGhost: false, extraContext: item.extraContext, objectType: item.objectType, xCoord: item.xCoord, yCoord: item.yCoord }; + }else if(item.objectType === RackTypes.GhostCage){ + const ghostCage: GhostCageData = await fetchGhostCage(item.cage); + return { + isGhost: true, + extraContext: item.extraContext, + objectType: item.objectType, + xCoord: item.xCoord, + yCoord: item.yCoord, + rackGroup: ghostCage.rackGroup, + groupRotation: ghostCage.groupRotation, + cage: ghostCage + }; } else { const cageHistory: CageHistoryData = await fetchCageHistory(item.historyId, item.cage); const cageData: CageData = await fetchCage(cageHistory.cage); const rackData: RackData = await fetchRack(cageData.rack); return { + isGhost: false, extraContext: item.extraContext, objectType: item.objectType, xCoord: item.xCoord, diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index e0d679dd2..02f3d8282 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -35,10 +35,12 @@ import { FetchRoomData, FullCageHistory, FullObjectHistoryData, + GhostCageData, GroupId, GroupRotation, LayoutData, - LayoutHistoryData, LoadedSvgs, + LayoutHistoryData, + LoadedSvgs, ModData, ModLocations, ModTypes, @@ -412,10 +414,20 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) ] }; - const [prevRoomResult, borderResult, modResult] = await Promise.all([ + const roomsConfig = { + schemaName: 'ehr_lookups', + queryName: 'rooms', + columns: ['species'], + filterArray: [ + Filter.create('room', roomName, Filter.Types.EQUALS), + ] + }; + + const [prevRoomResult, borderResult, modResult, roomsResult] = await Promise.all([ labkeyActionSelectWithPromise(prevRoomConfig, abortSignal), labkeyActionSelectWithPromise(prevRoomBorderConfig, abortSignal), - labkeyActionSelectWithPromise(modHistoryConfig, abortSignal) + labkeyActionSelectWithPromise(modHistoryConfig, abortSignal), + labkeyActionSelectWithPromise(roomsConfig, abortSignal) ]); let borderObj: LayoutData; @@ -481,6 +493,7 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) prevRoomData.prevRoomData = { name: roomName, + species: roomsResult.rows[0].species, cagingData: cagingData, layoutData: borderObj, isDefault: isDefaultRoom, @@ -589,9 +602,11 @@ export const addPrevRoomSvgs = async ( // this function renders the actual visible svg in some groups const createRackGroup = (parentGroup, rack: Rack, isSingleRack, groupRotation: GroupRotation) => { const rackTypeString: RackStringType = roomItemToString(rack.type.type) as RackStringType; + // Ghost racks have 0 item id + const isGhostRack = rack.itemId === 0; const rackGroup = isSingleRack ? parentGroup : parentGroup.append('g') - .attr('id', rack.objectId) + .attr('id', rack.svgId) .attr('class', `rack type-${rackTypeString}`) .attr('transform', `translate(${rack.x},${rack.y})`) .style('pointer-events', 'bounding-box'); @@ -610,17 +625,27 @@ export const addPrevRoomSvgs = async ( shape.classed('draggable', false); shape.style('pointer-events', 'none'); - // in order to set the event pass in the context menu ref and styles to show/hide it - (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum(cage.cageNum)}`; + if(!isGhostRack){ + (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum(cage.cageNum)}`; + } if (mode === 'view') { loadCageMods(cage, shape, groupRotation); + if(isGhostRack){ + shape.select('[id=cageRect]') + .style("fill", '#878787') + .style("opacity", '0.7'); + } } cageGroup.append(() => shape.node()); - // attach context menu if user has permissions for cages - if(canOpenContextMenu(user, rack.type.type)){ - setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); + // Dont attach menus to ghost racks + if(!isGhostRack){ + // attach context menu if user has permissions for cages + if(canOpenContextMenu(user, rack.type.type)){ + // in order to set the event pass in the context menu ref and styles to show/hide it + setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); + } } }); @@ -717,6 +742,7 @@ export const addPrevRoomSvgs = async ( export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, UnitLocations]> => { const newLocalRoom: Room = { name: prevRoom.name, + species: prevRoom.species, rackGroups: [], valid: false, objects: [], @@ -749,19 +775,24 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit //check if a rack exists for the rackId, if it does return, else create new rack for the group const findOrAddRack = async (rackGroup: RackGroup, rackItem: FullObjectHistoryData): Promise => { - let rackIdNum; + let rackIdNum: number; let rackObjectId; let extraContext: ExtraContext; let rackData = rackItem.rack as RackData; let rack: Rack; let rackCondition: RackConditions = RackConditions.Operational; - if (!prevRoom.isDefault) { + if(rackItem.isGhost){ + rackIdNum = (rackItem.cage as GhostCageData).rack; + rackObjectId = (rackItem.cage as GhostCageData).rackObjId; + rackCondition = RackConditions.Operational; + } + else if (!prevRoom.isDefault) { rackIdNum = rackData.rackId; rackObjectId = rackData.objectId; rackCondition = rackData.condition; } else { - rackIdNum = rackItem.rack; + rackIdNum = rackItem.rack as number; rackObjectId = `default-rack-${rackIdNum}`; } rack = rackGroup.racks.find(r => rackObjectId === r.objectId); @@ -772,7 +803,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit let typeRowId; const rackPrefix = prevRoom.isDefault ? 'default-rack' : 'rack'; - if (!prevRoom.isDefault) { + if (!prevRoom.isDefault && !rackItem.isGhost) { typeRowId = rackData.rackType; } @@ -782,7 +813,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit queryName: 'rack_types', columns: ['rowid', 'type', 'displayName', 'size', 'manufacturer/value', 'manufacturer/title', 'stationary'], filterArray: [ - Filter.create(prevRoom.isDefault ? 'type' : 'rowid', prevRoom.isDefault ? rackItem.objectType : typeRowId, Filter.Types.EQUALS) + Filter.create(prevRoom.isDefault || rackItem.isGhost ? 'type' : 'rowid', prevRoom.isDefault || rackItem.isGhost ? rackItem.objectType : typeRowId, Filter.Types.EQUALS) ] }; @@ -832,14 +863,22 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit let extraContext: ExtraContext; let cageHistoryData = (rackItem.cage as FullCageHistory)?.cageHistory; let cageData = (rackItem.cage as FullCageHistory)?.cageData; + let ghostCageData = (rackItem.cage as GhostCageData); let cageObjId: string; let cageNum; let cagePositionId; if (!prevRoom.isDefault) { - cageNum = cageHistoryData.cageNum; - cageObjId = cageHistoryData.cage; - cagePositionId = cageData.positionId; - cageNumType = roomItemToString(rackItem.objectType); + if(rackItem.objectType === RackTypes.GhostCage){ + cageNum = ghostCageData.cage; + cageObjId = ghostCageData.cageObjId; + cagePositionId = ghostCageData.positionId; + cageNumType = roomItemToString(rackItem.objectType); + }else{ + cageNum = cageHistoryData.cageNum; + cageObjId = cageHistoryData.cage; + cagePositionId = cageData.positionId; + cageNumType = roomItemToString(rackItem.objectType); + } } else { cageNum = rackItem.cage; cageObjId = generateUUID(); @@ -854,7 +893,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit const svgSize = await getSvgSize(rack.type.type); // This is where mods are loaded into state for the room - if (loadMods && !rack.type.isDefault) { + if (loadMods && !rack.type.isDefault && rack.type.type !== RackTypes.GhostCage) { cageMods = { [ModLocations.Top]: [], [ModLocations.Bottom]: [], diff --git a/CageUI/src/org/labkey/cageui/CageUIController.java b/CageUI/src/org/labkey/cageui/CageUIController.java index 7aba3f1e5..88a1deb5e 100644 --- a/CageUI/src/org/labkey/cageui/CageUIController.java +++ b/CageUI/src/org/labkey/cageui/CageUIController.java @@ -276,6 +276,7 @@ public void validateForm(SimpleApiJsonForm form, Errors errors) errors.reject(ERROR_MSG, e.getMessage()); } + RackTypesForm newRackType = CageUIManager.getRackType(getOption().getValue().getTypeRowId()); // Cages within new rack, ensure there is same number as in prev rack to be able to make a valid switch ArrayList newCagesForm = CageUIManager.getCagesInRack(getOption().getValue().getObjectId()); @@ -284,7 +285,10 @@ public void validateForm(SimpleApiJsonForm form, Errors errors) Manufacturer newManufacturer = CageUIManager.getRackManufacturer(newRackType.getManufacturer()); if (newRackType.getType() != getPrevRack().getType().getRackType().getNumericValue()) { - errors.reject(ERROR_MSG, "Racks have different types, cannot switch cages with pens, etc"); + // Ghost cages are exceptions to this rule + if(newRackType.getType() != RackTypes.GHOSTCAGE.getNumericValue() && getPrevRack().getType().getRackType().getNumericValue() != RackTypes.GHOSTCAGE.getNumericValue()){ + errors.reject(ERROR_MSG, "Racks have different types, cannot switch cages with pens, etc"); + } } Rack newRack = new Rack(); Rack.UnitType newType = new Rack.UnitType( diff --git a/CageUI/src/org/labkey/cageui/CageUIManager.java b/CageUI/src/org/labkey/cageui/CageUIManager.java index c01e49eb8..bb4d80cd5 100644 --- a/CageUI/src/org/labkey/cageui/CageUIManager.java +++ b/CageUI/src/org/labkey/cageui/CageUIManager.java @@ -21,14 +21,11 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategy; -import org.jetbrains.annotations.NotNull; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.cache.Cache; import org.labkey.api.cache.CacheManager; import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DbSchema; import org.labkey.api.data.DbSchemaType; import org.labkey.api.data.DbScope; @@ -44,14 +41,13 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; -import org.labkey.api.security.UserManager; import org.labkey.api.util.JsonUtil; import org.labkey.cageui.action.AllHistoryForm; import org.labkey.cageui.action.BundledForms; -import org.labkey.cageui.action.CageHistoryForm; import org.labkey.cageui.action.CageModificationHistoryForm; import org.labkey.cageui.action.CagesForm; import org.labkey.cageui.action.CagesFormWithContext; +import org.labkey.cageui.action.GhostCagesForm; import org.labkey.cageui.action.LayoutHistoryForm; import org.labkey.cageui.action.RackTypesForm; import org.labkey.cageui.action.RacksForm; @@ -64,6 +60,7 @@ import org.labkey.cageui.model.Rack; import org.labkey.cageui.model.RackCondition; import org.labkey.cageui.model.RackGroup; +import org.labkey.cageui.model.RackTypes; import org.labkey.cageui.model.Room; import org.labkey.cageui.model.RoomObject; import org.labkey.cageui.model.SessionLog; @@ -84,6 +81,8 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import org.junit.Assert; +import org.junit.Test; public class CageUIManager { @@ -293,6 +292,13 @@ public ApiSimpleResponse submitLayoutHistory(BundledForms newForms, User user, C throw new IllegalStateException(racksTable.getName() + " query update service"); } + TableInfo ghostCagesTable = cageUISchema.getTable("ghost_cages"); + QueryUpdateService ghostCagesQus = ghostCagesTable.getUpdateService(); + if (ghostCagesQus == null) + { + throw new IllegalStateException(ghostCagesTable.getName() + " query update service"); + } + try (DbScope.Transaction tx = CageUISchema.getInstance().getSchema().getScope().ensureTransaction()) { @@ -354,6 +360,11 @@ public ApiSimpleResponse submitLayoutHistory(BundledForms newForms, User user, C racksQus.updateRows(user, container, convertToMapList(newForms.getPrevRacksForm()), null, batchErrors, null, extraContext); } + if (newForms.getNewGhostCagesForm() != null) + { + ghostCagesQus.insertRows(user, container, convertToMapList(newForms.getNewGhostCagesForm()), batchErrors, null, extraContext); + } + if (batchErrors.hasErrors()) { response.put("success", false); @@ -412,11 +423,17 @@ public static AllHistoryForm getAllHistory(String room) return allHistory; } + // If rowid = 0 then this will get the ghost rack type public static RackTypesForm getRackType(int rowid) { TableInfo table = CageUISchema.getInstance().getRackTypesTable(); SimpleFilter filter = new SimpleFilter(); - filter.addCondition(FieldKey.fromString("rowid"), rowid, CompareType.EQUAL); + if(rowid == 0){ + // Ghost cage type value + filter.addCondition(FieldKey.fromString("type"), 8, CompareType.EQUAL); + }else{ + filter.addCondition(FieldKey.fromString("rowid"), rowid, CompareType.EQUAL); + } TableSelector selector = new TableSelector(table, filter, null); ObjectMapper mapper = JsonUtil.createDefaultMapper(); @@ -523,6 +540,7 @@ public static AllHistoryForm startNewAllHistory(String room, boolean isDefault, } public static Room createRoomWithReplacedRack(Room originalRoom, String prevRackObjectId, Rack newRack) { + // Create new room Room newRoom = new Room(); @@ -532,6 +550,11 @@ public static Room createRoomWithReplacedRack(Room originalRoom, String prevRack newRoom.setLayoutData(originalRoom.getLayoutData()); newRoom.setMods(originalRoom.getMods()); + RackTypes baseType = null; + boolean isNewRackGhost = newRack.getType().getRackType().isGhost(); + int cagesToRemoveCount = 0; + boolean foundRack = false; + // Copy rack groups with racks if (originalRoom.getRackGroups() != null) { List newRackGroups = new ArrayList<>(); @@ -551,8 +574,35 @@ public static Room createRoomWithReplacedRack(Room originalRoom, String prevRack if (originalRack != null && prevRackObjectId.equals(originalRack.getObjectId())) { // Replace the specific rack newRacks.add(newRack); + foundRack = true; + + RackTypes oldType = originalRack.getType().getRackType(); + RackTypes newType = newRack.getType().getRackType(); + baseType = oldType.getBaseType(); + + boolean wasOriginalRackGhost = oldType.isGhost(); + + if(originalRoom.getSpecies().equals("Rhesus")){ + if (!wasOriginalRackGhost && isNewRackGhost) { + // Transition from real to ghost - subsequent cages need to be decremented + cagesToRemoveCount = originalRack.getCages() != null ? originalRack.getCages().size() : 0; + } else if (wasOriginalRackGhost && !isNewRackGhost) { + // Transition from ghost to real - subsequent cages need to be incremented + cagesToRemoveCount = -(newRack.getCages() != null ? newRack.getCages().size() : 0); + } + } } else { // Keep the original rack + if (foundRack && cagesToRemoveCount != 0 && originalRack != null && !originalRack.getType().getRackType().isGhost()) { + // Update cage numbers for subsequent real racks of the same base type + if (originalRack.getType().getRackType().getBaseType() == baseType && originalRack.getCages() != null) { + for (Cage cage : originalRack.getCages()) { + int currentCageNum = findLastNumberAfterDash(cage.getCageNum()); + String prefix = cage.getCageNum().substring(0, cage.getCageNum().lastIndexOf('-') + 1); + cage.setCageNum(prefix + (currentCageNum - cagesToRemoveCount)); + } + } + } newRacks.add(originalRack); } } @@ -1024,6 +1074,7 @@ private void submitRealRoom(Room room, String historyId, BundledForms bundledFor ArrayList layoutForms = new ArrayList<>(); ArrayList racksToInsertList = new ArrayList<>(); ArrayList cagesToInsertList = new ArrayList<>(); + ArrayList ghostCagesToInsertList = new ArrayList<>(); Map> cagesExtraContextMap = new HashMap<>(); ArrayList racksToUpdateList = new ArrayList<>(); ArrayList cagesToUpdateList = new ArrayList<>(); @@ -1042,6 +1093,25 @@ private void submitRealRoom(Room room, String historyId, BundledForms bundledFor // Process racks in this group for (Rack rack : rackGroup.getRacks()) { + // Ghost Racks + if(rack.getType().getRackType() == RackTypes.GHOSTCAGE){ + for (Cage cage : rack.getCages()) + { + GhostCagesForm newGhostCage = new GhostCagesForm(); + // Always generate a new UUID for cage objects to prevent duplicates from being submitted. + String newObjId = UUID.randomUUID().toString().toUpperCase(); + cage.setObjectId(newObjId); + cage.setSvgId(RackTypes.getSvgName(rack.getType().getRackType()) + "_" + newObjId); + newGhostCage.setCageObjectId(newObjId); + newGhostCage.setPositionId(cage.getPositionId()); + newGhostCage.setRackGroup(findLastNumberAfterDash(rackGroup.getGroupId())); + newGhostCage.setRackObjectId(rack.getObjectId()); + newGhostCage.setGroupRotation(rackGroup.getRotation()); + newGhostCage.setCage(findLastNumberAfterDash(cage.getCageNum())); + ghostCagesToInsertList.add(newGhostCage); + } + continue; + } // Check if this is a new real rack that needs to be added to racks table if (rack.getIsNew() && !rack.getType().isDefault()) { @@ -1203,6 +1273,7 @@ else if (!rack.getIsNew() && !rack.getType().isDefault()) bundledForms.setPrevRacksForm(racksToUpdateList); bundledForms.setPrevCagesForm(prevCagesFormWithContext); bundledForms.setLayoutHistoryForm(layoutForms); + bundledForms.setNewGhostCagesForm(ghostCagesToInsertList); // Handle cage modifications history submitCageModificationsHistory(room, historyId, bundledForms); @@ -1216,6 +1287,9 @@ private void submitCageModificationsHistory(Room room, String historyId, Bundled { for (Rack rack : rackGroup.getRacks()) { + if(rack.getType().getRackType() == RackTypes.GHOSTCAGE){ + continue; + } if (rack.getCages() != null) { for (Cage cage : rack.getCages()) diff --git a/CageUI/src/org/labkey/cageui/CageUIModule.java b/CageUI/src/org/labkey/cageui/CageUIModule.java index 0ccbe402a..6bdd4e12d 100644 --- a/CageUI/src/org/labkey/cageui/CageUIModule.java +++ b/CageUI/src/org/labkey/cageui/CageUIModule.java @@ -59,7 +59,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 26.001; + return 26.002; } @Override diff --git a/CageUI/src/org/labkey/cageui/action/BundledForms.java b/CageUI/src/org/labkey/cageui/action/BundledForms.java index fb1e6dc74..845b6e96c 100644 --- a/CageUI/src/org/labkey/cageui/action/BundledForms.java +++ b/CageUI/src/org/labkey/cageui/action/BundledForms.java @@ -35,6 +35,7 @@ public class BundledForms ArrayList _prevRacksForm; CagesFormWithContext _newCagesForm; CagesFormWithContext _prevCagesForm; + ArrayList _newGhostCagesForm; public AllHistoryForm getNewAllHistoryForm() { @@ -155,4 +156,15 @@ public void setEhrRoomsForm(Map ehrRoomsForm) { _ehrRoomsForm = ehrRoomsForm; } + + public ArrayListgetNewGhostCagesForm() + { + return _newGhostCagesForm; + } + + public void setNewGhostCagesForm(ArrayList newGhostCagesForm) + { + _newGhostCagesForm = newGhostCagesForm; + } + } diff --git a/CageUI/src/org/labkey/cageui/action/GhostCagesForm.java b/CageUI/src/org/labkey/cageui/action/GhostCagesForm.java new file mode 100644 index 000000000..64b155978 --- /dev/null +++ b/CageUI/src/org/labkey/cageui/action/GhostCagesForm.java @@ -0,0 +1,107 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.action; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GhostCagesForm +{ + private int _rowid; + @JsonProperty("cage_objectid") + private String _cageObjectId; + @JsonProperty("positionid") + private int _positionId; + @JsonProperty("rack_group") + private int _rackGroup; + @JsonProperty("rack_objectid") + private String _rackObjectId; + @JsonProperty("group_rotation") + private int _groupRotation; + private int _cage; + + public int getRowid() + { + return _rowid; + } + + public void setRowid(int rowid) + { + _rowid = rowid; + } + + public String getCageObjectId() + { + return _cageObjectId; + } + + public void setCageObjectId(String cageObjectId) + { + _cageObjectId = cageObjectId; + } + + public int getPositionId() + { + return _positionId; + } + + public void setPositionId(int positionId) + { + _positionId = positionId; + } + + public int getRackGroup() + { + return _rackGroup; + } + + public void setRackGroup(int rackGroup) + { + _rackGroup = rackGroup; + } + + public String getRackObjectId() + { + return _rackObjectId; + } + + public void setRackObjectId(String rackObjectId) + { + _rackObjectId = rackObjectId; + } + + public int getGroupRotation() + { + return _groupRotation; + } + + public void setGroupRotation(int groupRotation) + { + _groupRotation = groupRotation; + } + + public int getCage() + { + return _cage; + } + + public void setCage(int cage) + { + _cage = cage; + } +} diff --git a/CageUI/src/org/labkey/cageui/model/RackTypes.java b/CageUI/src/org/labkey/cageui/model/RackTypes.java index 3a984a1ab..7453fcbb2 100644 --- a/CageUI/src/org/labkey/cageui/model/RackTypes.java +++ b/CageUI/src/org/labkey/cageui/model/RackTypes.java @@ -30,7 +30,8 @@ public enum RackTypes CAGE(4), PEN(5), TEMPCAGE(6), - PLAYCAGE(7); + PLAYCAGE(7), + GHOSTCAGE(8); private final int numericValue; @@ -78,6 +79,8 @@ public static String getName(RackTypes value) return "Temp Cage"; case PLAYCAGE: return "Play Cage"; + case GHOSTCAGE: + return "Ghost Cage"; default: throw new IllegalArgumentException("Invalid status value: " + value); } @@ -103,9 +106,38 @@ public static String getSvgName(RackTypes value) return "tempCage"; case PLAYCAGE: return "playCage"; + case GHOSTCAGE: + return "ghostCage"; default: throw new IllegalArgumentException("Invalid status value: " + value); } } + public boolean isGhost() + { + return this == GHOSTCAGE; + } + + public RackTypes getBaseType() + { + switch (this) + { + case DEFAULTCAGE: + case CAGE: + case GHOSTCAGE: + return CAGE; + case DEFAULTPEN: + case PEN: + return PEN; + case DEFAULTTEMPCAGE: + case TEMPCAGE: + return TEMPCAGE; + case DEFAULTPLAYCAGE: + case PLAYCAGE: + return PLAYCAGE; + default: + return this; + } + } + } diff --git a/CageUI/src/org/labkey/cageui/model/Room.java b/CageUI/src/org/labkey/cageui/model/Room.java index 9dfefd551..c78c625f4 100644 --- a/CageUI/src/org/labkey/cageui/model/Room.java +++ b/CageUI/src/org/labkey/cageui/model/Room.java @@ -27,6 +27,7 @@ public class Room { private String _name; + private String _species; private List _rackGroups; private List _objects; private LayoutData _layoutData; @@ -81,4 +82,14 @@ public void setMods(Map mods) { _mods = mods; } + + public String getSpecies() + { + return _species; + } + + public void setSpecies(String species) + { + _species = species; + } } diff --git a/WNPRC_EHR/resources/domain-templates/ehr_lookups.template.xml b/WNPRC_EHR/resources/domain-templates/ehr_lookups.template.xml new file mode 100644 index 000000000..71a1eca4f --- /dev/null +++ b/WNPRC_EHR/resources/domain-templates/ehr_lookups.template.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/ehr_lookups/rooms.query.xml b/WNPRC_EHR/resources/queries/ehr_lookups/rooms.query.xml new file mode 100644 index 000000000..296cda14a --- /dev/null +++ b/WNPRC_EHR/resources/queries/ehr_lookups/rooms.query.xml @@ -0,0 +1,41 @@ + + + + + + + /EHR/cageDetails.view?room=${room} + + + true + + + + ehr_lookups + species + common + + + + room + room +
+
+
+
From fdedd2ccf491a541772dd6fdba72e161888af377 Mon Sep 17 00:00:00 2001 From: aschmidt34 <124093649+aschmidt34@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:25:06 -0500 Subject: [PATCH 3/5] =?UTF-8?q?Fixed=20issue=20with=20date-parsing=20error?= =?UTF-8?q?=20preventing=20the=20'Water=20Monitoring=20=E2=80=A6=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated date so it parses correctly and sends the notification. Left comment above code change describing why the fix was required. ## Rationale ## Related Pull Requests - ## Changes - --- .../wnprc_ehr/notification/WaterMonitoringNotification.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/WaterMonitoringNotification.java b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/WaterMonitoringNotification.java index d879b4aba..f72ea013c 100644 --- a/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/WaterMonitoringNotification.java +++ b/WNPRC_EHR/src/org/labkey/wnprc_ehr/notification/WaterMonitoringNotification.java @@ -186,7 +186,11 @@ protected void findAnimalsWithEnoughWater(final Container c, final User u, final String mlsPerKg; String totalWater; for(Map mapItem : totalWaterByProject){ - LocalDateTime objectDateTime = ConvertHelper.convert(mapItem.get("date"),Date.class).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + // Casts date & time manually before formatting. + // Using 'toInstant()' without casting first will throw an error due to ConvertHelper.convert() returning a 'java.sql.Date' instead of the 'java.util.Date' required by toInstant(). + Date rawDate = (Date) ConvertHelper.convert(mapItem.get("date"), Date.class); + Date utilDate = new Date(rawDate.getTime()); + LocalDateTime objectDateTime = utilDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); mlsPerKg = ConvertHelper.convert(mapItem.get("mlsPerKg"),String.class) == null ? " " : ConvertHelper.convert(mapItem.get("mlsPerKg"),String.class); From 12dc9b920c4648d25033d05a10bcf17081b459ae Mon Sep 17 00:00:00 2001 From: LeviCameron1 <86750204+LeviCameron1@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:12:35 -0500 Subject: [PATCH 4/5] Switch line direction of dividers and floors (#1015) ## Rationale Users requested the line rotation be switched to match how they will usually structure layouts. ## Related Pull Requests ## Changes - Update legend svg to rotate the lines for floors and dividers --- CageUI/resources/web/CageUI/static/legend.svg | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/CageUI/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index 51a97407f..c8ca0da37 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -18,44 +18,46 @@ --> - - - - - - + - + - + - + - - - - - + - - - - + @@ -101,7 +103,7 @@ - - From bdb3b8d12e64eeeed4fe2952064e760d34186e99 Mon Sep 17 00:00:00 2001 From: LeviCameron1 <86750204+LeviCameron1@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:02:14 -0500 Subject: [PATCH 5/5] Add adoptions (#1018) ## Rationale This table and form will be used later by the new housing to determine condition codes. ## Related Pull Requests ## Changes - Added data entry for adoptions - Permissions for adoptions --- CageUI/package-lock.json | 688 +++++++++++++++++- CageUI/package.json | 7 + .../postgresql/cageui-26.001-26.002.sql | 33 + CageUI/src/client/api/labkeyActions.ts | 17 + CageUI/src/client/cageui.scss | 61 +- .../components/AutoCompleteEditCell.tsx | 85 +++ .../client/components/DateTimeGridField.tsx | 180 +++++ .../adoptionDataEntry/AdoptionForm.tsx | 445 +++++++++++ CageUI/src/client/entryPoints.js | 9 + .../adoptionDataEntry/AdoptionDataEntry.tsx | 85 +++ .../client/pages/adoptionDataEntry/app.tsx | 30 + .../client/pages/adoptionDataEntry/dev.tsx | 29 + CageUI/src/client/types/adoptionFormTypes.ts | 41 ++ .../org/labkey/cageui/CageUIController.java | 203 ++++++ .../src/org/labkey/cageui/CageUIManager.java | 19 + .../cageui/action/AdoptionDataForm.java | 85 +++ .../org/labkey/cageui/model/AdoptionData.java | 105 +++ .../org/labkey/cageui/model/AdoptionType.java | 51 ++ .../src/org/labkey/cageui/model/Option.java | 78 ++ .../CageUIAdoptionsPermission.java | 30 + .../security/roles/CageUIAdoptionsRole.java | 41 ++ .../queries/study/adoptions.query.xml | 64 ++ .../queries/study/adoptions/.qview.xml | 28 + .../queries/study/adoptionsOngoing.sql | 33 + .../queries/study/adoptionsSuccess.sql | 40 + 25 files changed, 2443 insertions(+), 44 deletions(-) create mode 100644 CageUI/src/client/components/AutoCompleteEditCell.tsx create mode 100644 CageUI/src/client/components/DateTimeGridField.tsx create mode 100644 CageUI/src/client/components/adoptionDataEntry/AdoptionForm.tsx create mode 100644 CageUI/src/client/pages/adoptionDataEntry/AdoptionDataEntry.tsx create mode 100644 CageUI/src/client/pages/adoptionDataEntry/app.tsx create mode 100644 CageUI/src/client/pages/adoptionDataEntry/dev.tsx create mode 100644 CageUI/src/client/types/adoptionFormTypes.ts create mode 100644 CageUI/src/org/labkey/cageui/action/AdoptionDataForm.java create mode 100644 CageUI/src/org/labkey/cageui/model/AdoptionData.java create mode 100644 CageUI/src/org/labkey/cageui/model/AdoptionType.java create mode 100644 CageUI/src/org/labkey/cageui/model/Option.java create mode 100644 CageUI/src/org/labkey/cageui/security/permissions/CageUIAdoptionsPermission.java create mode 100644 CageUI/src/org/labkey/cageui/security/roles/CageUIAdoptionsRole.java create mode 100644 WNPRC_EHR/resources/queries/study/adoptions.query.xml create mode 100644 WNPRC_EHR/resources/queries/study/adoptions/.qview.xml create mode 100644 WNPRC_EHR/resources/queries/study/adoptionsOngoing.sql create mode 100644 WNPRC_EHR/resources/queries/study/adoptionsSuccess.sql diff --git a/CageUI/package-lock.json b/CageUI/package-lock.json index 8d9e2834d..5faf07a69 100644 --- a/CageUI/package-lock.json +++ b/CageUI/package-lock.json @@ -9,13 +9,20 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@labkey/api": "1.51.5", "@labkey/components": "7.43.0", + "@mui/icons-material": "^9.0.1", + "@mui/material": "^9.0.1", + "@mui/x-data-grid": "^8.28.6", + "@mui/x-date-pickers": "^9.2.0", "d3": "^7.9.0", "dayjs": "^1.11.21", "react": "~18.3.1", "react-bootstrap": "~2.10.10", "react-dom": "~18.3.1", + "react-is": "^18.3.1", "react-select": "^5.10.2", "react-svg": "^16.4.2" }, @@ -1727,6 +1734,28 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", + "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -1929,20 +1958,14 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { - "@emotion/memoize": "0.7.4" + "@emotion/memoize": "^0.9.0" } }, - "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "license": "MIT" - }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", @@ -1993,17 +2016,26 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.3.0.tgz", - "integrity": "sha512-GgcUpXBBEU5ido+/p/mCT2/Xx+Oqmp9JzQRuC+a4lYM4i4LBBn/dWvc0rQ19N9ObA8/T4NWMrPNe79kMBDJqoQ==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { - "@emotion/styled-base": "^10.3.0", - "babel-plugin-emotion": "^10.0.27" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { - "@emotion/core": "^10.0.27", - "react": ">=16.3.0" + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@emotion/styled-base": { @@ -2028,6 +2060,15 @@ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, + "node_modules/@emotion/styled-base/node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, "node_modules/@emotion/styled-base/node_modules/@emotion/memoize": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", @@ -2153,9 +2194,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@hello-pangea/dnd": { @@ -2782,6 +2823,581 @@ "dev": true, "license": "MIT" }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.3.1.tgz", + "integrity": "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.3.1.tgz", + "integrity": "sha512-rZj5ccG7vkpV38o/l4ys+chfE9GFypmvZr9dSNBoYVCNBD9yC6KfKq1TYpEMshWbjic7GKQ8MA1LQlcpGgcq9Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.3.1.tgz", + "integrity": "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/core-downloads-tracker": "^9.3.1", + "@mui/system": "^9.3.0", + "@mui/types": "^9.3.0", + "@mui/utils": "^9.3.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.8", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.3.0.tgz", + "integrity": "sha512-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.3.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.3.0.tgz", + "integrity": "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.3.0.tgz", + "integrity": "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/private-theming": "^9.3.0", + "@mui/styled-engine": "^9.3.0", + "@mui/types": "^9.3.0", + "@mui/utils": "^9.3.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.3.0.tgz", + "integrity": "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.3.0.tgz", + "integrity": "sha512-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/types": "^9.3.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/x-data-grid": { + "version": "8.29.2", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.29.2.tgz", + "integrity": "sha512-AnH3yQJZXUB+Dv2CtSx8J1XBU1y4GTbq3MCC+r0CCTtglB032K4UhbyeMXG6FLFqWDn1nKofcBNYVoNv5ELR4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.29.2", + "@mui/x-virtualizer": "0.4.1", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/x-date-pickers": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-9.11.0.tgz", + "integrity": "sha512-3vLmkn1wG+hNBaGobnk/9R8APCRhOiAXBTC78m7wZ2OWZ2NoMqi+O09HwTGlIgK6mScZZCeCQTSj5phNLrK1hw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.3.0", + "@mui/x-internals": "^9.11.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.11.0.tgz", + "integrity": "sha512-JjKe9k1+gVWNPwMTZLNSc92eVmoZqP5Xq3Ui6mJkstotUrbWIZC0o9+4AfTR1lYqWwnKmqm+VtLLVCa7MEBXWQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@base-ui/utils": "^0.3.1", + "@mui/utils": "^9.3.0", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals": { + "version": "8.29.2", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.29.2.tgz", + "integrity": "sha512-TLILyHia5NHh3MGErFDh0bXZ4V6iS45hdStofsV5wklF6dpgZjduo65oJB0h81mI5mexNlhHANEVfw0KV6BxBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@mui/x-virtualizer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.4.1.tgz", + "integrity": "sha512-EM4lCW9MFRHxAgdqBG6nRQXlrhctlHqOjPm95usht15JBXtuWwtuVdA5q8ta+EwXE9zF0BcKyQsGs8R/vWzmFw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-virtualizer/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-virtualizer/node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-virtualizer/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", @@ -8929,6 +9545,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9199,9 +9821,9 @@ } }, "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -9365,6 +9987,20 @@ "react-dom": ">=16.7.0" } }, + "node_modules/react-treebeard/node_modules/@emotion/styled": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.3.0.tgz", + "integrity": "sha512-GgcUpXBBEU5ido+/p/mCT2/Xx+Oqmp9JzQRuC+a4lYM4i4LBBn/dWvc0rQ19N9ObA8/T4NWMrPNe79kMBDJqoQ==", + "license": "MIT", + "dependencies": { + "@emotion/styled-base": "^10.3.0", + "babel-plugin-emotion": "^10.0.27" + }, + "peerDependencies": { + "@emotion/core": "^10.0.27", + "react": ">=16.3.0" + } + }, "node_modules/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -9568,6 +10204,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", diff --git a/CageUI/package.json b/CageUI/package.json index 28708475a..a9a490598 100644 --- a/CageUI/package.json +++ b/CageUI/package.json @@ -18,13 +18,20 @@ "author": "Board of Regents of the University of Wisconsin System", "license": "Apache-2.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@labkey/api": "1.51.5", "@labkey/components": "7.43.0", + "@mui/icons-material": "^9.0.1", + "@mui/material": "^9.0.1", + "@mui/x-data-grid": "^8.28.6", + "@mui/x-date-pickers": "^9.2.0", "d3": "^7.9.0", "dayjs": "^1.11.21", "react": "~18.3.1", "react-bootstrap": "~2.10.10", "react-dom": "~18.3.1", + "react-is": "^18.3.1", "react-select": "^5.10.2", "react-svg": "^16.4.2" }, diff --git a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql index d20ac7610..0e58c0893 100644 --- a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql +++ b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.001-26.002.sql @@ -40,3 +40,36 @@ select setname, container, 8 as value, 'Caging' as category, 'Ghost Cage' as tit insert into ehr_lookups.lookups (set_name,container,value, title) select setname, container, 'ghostCage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +INSERT INTO ehr_lookups.lookup_sets (setname, label, description, keyField, container) +select 'adoption_status' as setname, + 'Adoption Status Field Values' as label, + 'List of possible adoption progress statuses' as description, + 'value' as keyField, + container from ehr_lookups.lookup_sets where setname='ancestry'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 0 as value, 'Start' as title from ehr_lookups.lookup_sets where setname='adoption_status'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 1 as value, 'End' as title from ehr_lookups.lookup_sets where setname='adoption_status'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 2 as value, 'Pause' as title from ehr_lookups.lookup_sets where setname='adoption_status'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 3 as value, 'Resume' as title from ehr_lookups.lookup_sets where setname='adoption_status'; + + +INSERT INTO ehr_lookups.lookup_sets (setname, label, description, keyField, container) +select 'adoption_results' as setname, + 'Adoption Result Field Values' as label, + 'List of possible adoption results' as description, + 'value' as keyField, + container from ehr_lookups.lookup_sets where setname='ancestry'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 0 as value, 'Success' as title from ehr_lookups.lookup_sets where setname='adoption_results'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 1 as value, 'Failure' as title from ehr_lookups.lookup_sets where setname='adoption_results'; \ No newline at end of file diff --git a/CageUI/src/client/api/labkeyActions.ts b/CageUI/src/client/api/labkeyActions.ts index 7a9451d59..5e6fbbf39 100644 --- a/CageUI/src/client/api/labkeyActions.ts +++ b/CageUI/src/client/api/labkeyActions.ts @@ -19,6 +19,7 @@ import { ActionURL, Ajax, Query, Security, Utils } from '@labkey/api'; import { CageMods, Rack, RackConditionOption, Room, SessionLog } from '../types/typings'; import { buildURL } from '@labkey/components'; import { RackSwitchOption } from '../types/homeTypes'; +import { AdoptionData } from '../types/adoptionFormTypes'; export function labkeyActionSelectWithPromise( options: Query.SelectRowsOptions, @@ -193,4 +194,20 @@ export function updateRackConditionStatus(rack: RackSwitchOption, condition: Rac jsonData: {rack: rack.value.objectId, condition: condition.value}, }); }); +} + +// This function is for submitting a adoption form. +export function startAdoptionSubmission(animals: AdoptionData[]): Promise<{ + success: boolean, + errors: any[] +}> { + return new Promise((resolve, reject) => { + Ajax.request({ + url: buildURL('cageui', 'submitAdoptionForm.api'), + method: 'POST', + success: (res) => resolve(JSON.parse(res.response)), + failure: Utils.getCallbackWrapper((error) => reject(error)), + jsonData: {adoptionData: animals}, + }); + }); } \ No newline at end of file diff --git a/CageUI/src/client/cageui.scss b/CageUI/src/client/cageui.scss index a3b6bc49a..9db3e427d 100644 --- a/CageUI/src/client/cageui.scss +++ b/CageUI/src/client/cageui.scss @@ -673,7 +673,7 @@ } .loading-overlay { - position: absolute; + position: fixed; top: 0; left: 0; width: 100%; @@ -681,22 +681,18 @@ background-color: rgba(0, 0, 0, 0.30); display: flex; justify-content: center; - align-items: start; + align-items: center; border-radius: 8px; z-index: 9999; backdrop-filter: blur(5px); } .loading-content { - position: sticky; - top: 25%; - left: 50%; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; - padding-top: 20px; } .spinner { @@ -1711,9 +1707,6 @@ margin-top: 0px; box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); } - - - .modification-editor { } @@ -1724,13 +1717,6 @@ margin-top: 0px; border-bottom: lightgrey 5px solid; } -.modification-editor-input { - width: 100%; - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1rem; -} .modification-editor-content { margin-bottom: 20px; display: flex; @@ -1738,9 +1724,6 @@ margin-top: 0px; flex-direction: row; } - - - @keyframes fadeIn { from { opacity: 0; @@ -1752,8 +1735,7 @@ margin-top: 0px; } } - -.animal-editor{ +.animal-editor { } @@ -1763,6 +1745,7 @@ margin-top: 0px; } + /* Multi Dropdown Css @@ -2217,3 +2200,39 @@ Multi Dropdown Css height: 100% !important; overflow: hidden !important; } + + +.MuiDataGrid-form-container { + display: grid; + grid-template-columns: minmax(0, 1fr); + position: relative; + width: 100%; + gap: 10px; + + .MuiDataGrid-root { + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + width: 100%; + overflow: hidden; // Ensure DataGrid handles its own internal overflow + + .MuiDataGrid-main { + min-width: 0; + } + + .MuiDataGrid-cell:focus-within { + outline: none; + } + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 15px; + padding: 20px; + background: white; + border-radius: 8px; + box-shadow: 0 -2px 10px rgba(0,0,0,0.05); + position: sticky; + bottom: 0; +} \ No newline at end of file diff --git a/CageUI/src/client/components/AutoCompleteEditCell.tsx b/CageUI/src/client/components/AutoCompleteEditCell.tsx new file mode 100644 index 000000000..1608fe743 --- /dev/null +++ b/CageUI/src/client/components/AutoCompleteEditCell.tsx @@ -0,0 +1,85 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ +import * as React from 'react'; +import { useState } from 'react'; +import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid'; +import { Autocomplete, TextField } from '@mui/material'; + +interface AutoCompleteEditCellParams { + options: any[] + required: boolean; + multiple?: boolean; + disableClearable?: boolean; + returnValueOnly?: boolean; +} + +export const AutoCompleteEditCell = (props: GridRenderEditCellParams & AutoCompleteEditCellParams) => { + const { id, field, value, options, required, multiple, disableClearable, returnValueOnly } = props; + const apiRef = useGridApiContext(); + const [open, setOpen] = useState(true); + + const handleChange = (event: any, newValue: any) => { + const val = returnValueOnly && newValue ? newValue.value : newValue; + apiRef.current.setEditCellValue({ id, field, value: val }); + if (!multiple && (newValue || newValue === null)) { + apiRef.current.stopCellEditMode({ id, field }); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Tab') { + apiRef.current.stopCellEditMode({ id, field }); + } + }; + + const isError = required && (value === null || value === undefined || (Array.isArray(value) && value.length === 0) || value === ''); + const selectedOption = multiple ? (value || []) : (options.find(opt => opt.value === value || opt === value || (typeof value === 'object' && value !== null && opt.value === value.value)) || null); + + return ( + option.label || ''} + value={selectedOption} + onChange={handleChange} + open={open} + onOpen={() => setOpen(true)} + onClose={(event, reason) => { + if (reason === 'selectOption' || reason === 'blur' || reason === 'escape') { + setOpen(false); + } + }} + fullWidth + multiple={multiple} + disableClearable={disableClearable} + isOptionEqualToValue={(option, value) => { + const val = (value && typeof value === 'object' && 'value' in value) ? value.value : value; + return option.value === val; + }} + renderInput={(params) => ( + + )} + /> + ); +}; \ No newline at end of file diff --git a/CageUI/src/client/components/DateTimeGridField.tsx b/CageUI/src/client/components/DateTimeGridField.tsx new file mode 100644 index 000000000..bd44ffe0a --- /dev/null +++ b/CageUI/src/client/components/DateTimeGridField.tsx @@ -0,0 +1,180 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ +import * as React from 'react'; +import { + DataGrid, + GridColDef, + GridRowsProp, + useGridApiContext, + GridRenderEditCellParams, + GRID_DATE_COL_DEF, + GRID_DATETIME_COL_DEF, + GridColTypeDef, + GridFilterInputValueProps, + getGridDateOperators, +} from '@mui/x-data-grid'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; // Says this import is unused but will break typescript if removed +import { enUS as locale } from 'date-fns/locale'; +import { format } from 'date-fns/format'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import { Dayjs } from 'dayjs'; + +AdapterDateFns; // this is here to prevent intellij/ide "remove unused imports" from cleaning up the required import above. + +// Check out the code used here at this link to explain it further. (MUI X v9.3.0) +// https://mui.com/x/react-data-grid/custom-columns/#date-pickers +/** + * `date` column + */ + +const dateColumnType: GridColTypeDef = { + ...GRID_DATE_COL_DEF, + resizable: false, + renderEditCell: (params) => { + return ; + }, + filterOperators: getGridDateOperators(false).map((item) => ({ + ...item, + InputComponent: GridFilterDateInput, + InputComponentProps: { showTime: false }, + })), + valueFormatter: (value) => { + if (value) { + return format(value, 'MM/dd/yyyy', { locale }); + } + return ''; + }, +}; + +function GridEditDateCell({ + id, + field, + value, + colDef, + hasFocus, + }: GridRenderEditCellParams) { + const apiRef = useGridApiContext(); + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(true); + const Component = colDef.type === 'dateTime' ? DateTimePicker : DatePicker; + + const handleChange = (newValue: unknown) => { + apiRef.current.setEditCellValue({ id, field, value: newValue }); + }; + + const handleAccept = (newValue: unknown) => { + apiRef.current.setEditCellValue({ id, field, value: newValue }); + apiRef.current.stopCellEditMode({ id, field }); + }; + + const handleClose = () => { + setOpen(false); + apiRef.current.stopCellEditMode({ id, field }); + }; + + useEnhancedEffect(() => { + if (hasFocus) { + inputRef.current!.focus(); + } + }, [hasFocus]); + + return ( + setOpen(true)} + onClose={handleClose} + onChange={handleChange} + onAccept={handleAccept} + closeOnSelect={false} + timeSteps={{ minutes: 1 }} + slotProps={{ + actionBar: { + actions: ['cancel', 'accept'], + }, + textField: { + inputRef, + variant: 'standard', + fullWidth: true, + sx: { + padding: '0 9px', + justifyContent: 'center', + '& .MuiInput-underline:after': { + borderBottomColor: value ? 'primary' : 'error.main', + }, + }, + error: !value, + slotProps: { + input: { + disableUnderline: false, + sx: { fontSize: 'inherit' }, + }, + }, + }, + }} + /> + ); +} + +function GridFilterDateInput( + props: GridFilterInputValueProps & { showTime?: boolean }, +) { + const { item, showTime, applyValue, apiRef } = props; + + const Component = showTime ? DateTimePicker : DatePicker; + + const handleFilterChange = (newValue: unknown) => { + applyValue({ ...item, value: newValue }); + }; + + return ( + + ); +} + +/** + * `dateTime` column + */ + +export const dateTimeColumnType: GridColTypeDef = { + ...GRID_DATETIME_COL_DEF, + resizable: true, + renderEditCell: (params) => { + return ; + }, + filterOperators: getGridDateOperators(true).map((item) => ({ + ...item, + InputComponent: GridFilterDateInput, + InputComponentProps: { showTime: true }, + })), + valueFormatter: (value: Dayjs) => { + if (value) { + return format(value.toDate(), 'MM/dd/yyyy hh:mm a', { locale }); + } + return ''; + }, +}; diff --git a/CageUI/src/client/components/adoptionDataEntry/AdoptionForm.tsx b/CageUI/src/client/components/adoptionDataEntry/AdoptionForm.tsx new file mode 100644 index 000000000..0e5a1f865 --- /dev/null +++ b/CageUI/src/client/components/adoptionDataEntry/AdoptionForm.tsx @@ -0,0 +1,445 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + + +import * as React from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { + DataGrid, + GridAutosizeOptions, + GridColDef, + GridRenderCellParams, + GridRowModel, + useGridApiRef, + useGridApiContext, + GridRenderEditCellParams, GridCellParams +} from '@mui/x-data-grid'; +import { Autocomplete, Box, Button, IconButton, TextField } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import dayjs from 'dayjs'; +import { AdoptionData, AdoptionResult, AdoptionStatus } from '../../types/adoptionFormTypes'; +import { dateTimeColumnType } from '../DateTimeGridField'; +import { generateUUID } from '../../utils/helpers'; +import { ActionURL, Filter, Query } from '@labkey/api'; +import { labkeyActionSelectWithPromise, startAdoptionSubmission } from '../../api/labkeyActions'; +import { AutoCompleteEditCell } from '../AutoCompleteEditCell'; +import { LoadingScreen } from '../LoadingScreen'; +import { LayoutErrors } from '../LayoutErrors'; + +interface AdoptionFormProps { + prevForm?: AdoptionData; +} + +export const AdoptionForm: FC = (props) => { + const {prevForm} = props; + const [animals, setAnimals] = useState(prevForm ? [prevForm] : []); + const [centerAnimals, setCenterAnimals] = useState([]); + const [errorMsg, setErrorMsg] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const apiRef = useGridApiRef(); + const [autoSizeOptions] = useState({ + includeHeaders: true, + includeOutliers: true, + expand: true, + outliersFactor: 1.5, + }); + + useEffect(() => { + if (apiRef.current) { + const timeout = setTimeout(() => { + apiRef.current?.autosizeColumns(autoSizeOptions); + }, 250); + return () => clearTimeout(timeout); + } + }, [apiRef, animals, autoSizeOptions]); + + useEffect(() => { + const config: Query.SelectRowsOptions = { + schemaName: 'study', + queryName: 'demographics', + viewName: 'Alive, at Center', + columns: ['Id'] + }; + + labkeyActionSelectWithPromise(config).then(result => { + if (result.rows.length !== 0) { + const rowOptions: string[] = []; + result.rows.forEach(row => { + rowOptions.push(row.Id); + }); + setCenterAnimals(rowOptions); + } + }).catch(err => { + console.error('Error fetching alive at center animals', err); + }); + }, []); + + const handleAddAnimal = useCallback(() => { + const newAnimal: AdoptionData = { + objectid: generateUUID(), + id: '', + date: dayjs(), + dam: null, + sire: null, + type: { + label: AdoptionStatus[AdoptionStatus.Start] as keyof typeof AdoptionStatus, + value: AdoptionStatus.Start + }, + result: null + }; + setAnimals(prev => [...prev, newAnimal]); + }, []); + + const handleDeleteRow = useCallback((objectid: string) => { + setAnimals(prev => prev.filter(animal => animal.objectid !== objectid)); + }, []); + + const processRowUpdate = useCallback((newRow: GridRowModel, oldRow: GridRowModel) => { + if (newRow.type && newRow.type.value !== AdoptionStatus.End) { + newRow.result = null; + } + + if (!prevForm && newRow.id && newRow.id !== oldRow.id) { + const config: Query.SelectRowsOptions = { + schemaName: 'study', + queryName: 'adoptionsOngoing', + filterArray: [Filter.create('Id', newRow.id, Filter.Types.EQUAL)], + columns: ['dam'] + }; + + labkeyActionSelectWithPromise(config).then(result => { + if (result.rows.length !== 0) { + const damId = result.rows[0].dam; + setAnimals(prev => prev.map(row => (row.objectid === newRow.objectid ? { ...newRow, dam: damId } : row))); + } + }).catch(err => { + console.error('Error fetching ongoing adoption for dam ID', err); + }); + } + + setAnimals(prev => prev.map(row => (row.objectid === newRow.objectid ? newRow : row))); + return newRow; + }, [prevForm]); + + const handleCellClick = useCallback((params: GridCellParams) => { + if (params.isEditable && params.cellMode === 'view') { + apiRef.current.startCellEditMode({ id: params.id, field: params.field }); + } + }, [apiRef]); + + const adoptionStatusOptions = useMemo(() => { + return Object.keys(AdoptionStatus) + .filter(key => isNaN(Number(key))) + .map(key => ({ + label: key, + value: AdoptionStatus[key as keyof typeof AdoptionStatus] + })); + }, []); + + const adoptionResultOptions = useMemo(() => { + return Object.keys(AdoptionResult) + .filter(key => isNaN(Number(key))) + .map(key => ({ + label: key, + value: AdoptionResult[key as keyof typeof AdoptionResult] + })); + }, []); + + const centerAnimalsOptions = useMemo(() => { + return centerAnimals.map(animalId => ({ + label: animalId, + value: animalId + })); + }, [centerAnimals]); + + const columns: GridColDef[] = useMemo(() => [ + { + field: 'id', + headerName: 'Infant Id', + minWidth: 100, + flex: 1, + display: 'flex', + editable: true, + renderEditCell: (params) => ( + + ), + valueFormatter: (value) => { + if (value === undefined || value === null) return ''; + return value; + } + }, + { + field: 'date', + headerName: 'Date', + ...dateTimeColumnType, + minWidth: 180, + flex: 1, + display: 'flex', + editable: true + }, + { + field: 'dam', + headerName: 'Foster Dam', + minWidth: 120, + flex: 1, + display: 'flex', + editable: true, + renderEditCell: (params) => ( + + ), + valueFormatter: (value) => { + if (value === undefined || value === null) return ''; + return value; + } + }, + { + field: 'sire', + headerName: 'Foster Sire', + minWidth: 120, + flex: 1, + display: 'flex', + editable: true, + renderEditCell: (params) => ( + + ), + valueFormatter: (value) => { + if (value === undefined || value === null) return ''; + return value; + } + }, + { + field: 'type', + headerName: 'Type', + minWidth: 120, + flex: 1, + display: 'flex', + editable: true, + renderEditCell: (params) => ( + + ), + valueFormatter: (value) => { + const val = (value as any)?.value !== undefined ? (value as any).value : value; + if (val === undefined || val === null) return ''; + return AdoptionStatus[val as number] || ''; + } + }, + { + field: 'result', + headerName: 'Result', + minWidth: 120, + flex: 1, + display: 'flex', + editable: true, + renderEditCell: (params) => { + if(params.row.type?.value !== AdoptionStatus.End){ + return; + } + return( + + ); + }, + valueFormatter: (value) => { + const val = (value as any)?.value !== undefined ? (value as any).value : value; + if (val === undefined || val === null) return ''; + return AdoptionResult[val as number] || ''; + }, + isCellEditable: (params) => params.row.type?.value === AdoptionStatus.End + }, + { + field: 'actions', + headerName: 'Actions', + sortable: false, + minWidth: 80, + display: 'flex', + renderCell: (params: GridRenderCellParams) => ( + handleDeleteRow(params.row.objectid)} color="error"> + + + ), + } + ], [adoptionStatusOptions, adoptionResultOptions, centerAnimalsOptions, handleDeleteRow]); + + const isRowValid = useCallback((row: AdoptionData) => { + const { id, date, type, result } = row; + const isTypeEnd = type?.value === AdoptionStatus.End; + + return ( + id !== '' && id !== null && id !== undefined && + date !== null && date !== undefined && + type !== null && type !== undefined && + (!isTypeEnd || (result !== null && result !== undefined)) + ); + }, []); + + const isFormValid = useMemo(() => { + return animals.length > 0 && animals.every(isRowValid); + }, [animals, isRowValid]); + + const getCellClassName = useCallback((params: GridCellParams) => { + const { field, value, row } = params; + + const isRequired = + field === 'id' || + field === 'date' || + field === 'type' || + (field === 'result' && row.type?.value === AdoptionStatus.End); + + if (isRequired && (value === null || value === undefined || value === '')) { + return 'required-field-error'; + } + + return ''; + }, []); + + const handleSubmit = useCallback(() => { + console.log('Submitting form...', animals); + startAdoptionSubmission(animals).then((res) => { + if(res.success){ + // Housing transfer complete + window.location.href = ActionURL.buildURL( + "query", + 'executeQuery', + ActionURL.getContainer(), + {schemaName: "study", queryName: "adoptions"}); + }else{ + // If this happens, the issue is likely related to a faulty submission in the java portion that didn't throw + // an error correctly. Otherwise, it would have gotten caught in the catch below. + setErrorMsg(["Unknown Error Occurred"]); + } + setIsSaving(false); + }).catch(err => { + if(err.errors){ + setErrorMsg(err.errors.map(e => e.msg)); + }else{ + setErrorMsg(err); + } + setIsSaving(false); + }); + }, [animals]); + + return ( + + + {!prevForm && + + + + } + + + row.objectid} + getRowHeight={() => 'auto'} + disableRowSelectionOnClick + autosizeOptions={autoSizeOptions} + columnVisibilityModel={{ + actions: !prevForm, + }} + autosizeOnMount + sx={{ + '& .required-field-error': { + backgroundColor: '#ffebee', // Light red background + '&:hover': { + backgroundColor: '#ffcdd2', + }, + }, + '& .MuiDataGrid-cell': { + display: 'flex', + alignItems: 'center', + padding: '8px', + whiteSpace: 'normal', + wordBreak: 'break-word', + }, + '& .MuiDataGrid-cellContent': { + width: '100%', + display: 'flex', + alignItems: 'center', + }, + '& .MuiInputBase-root': { + height: 'auto', + minHeight: '100%', + }, + '& .MuiOutlinedInput-root': { + height: 'auto', + }, + '& .MuiAutocomplete-root': { + width: '100%', + }, + '& .MuiTextField-root': { + width: '100%', + }, + '& .MuiDateTimePicker': { + height: '100%', + }, + '& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': { + margin: 0, + }, + }} + /> + + {animals.length > 0 && ( +
+ +
+ )} + {errorMsg.length > 0 && } + + ); +}; + diff --git a/CageUI/src/client/entryPoints.js b/CageUI/src/client/entryPoints.js index 59fa2c8f2..6e27e447f 100644 --- a/CageUI/src/client/entryPoints.js +++ b/CageUI/src/client/entryPoints.js @@ -40,6 +40,15 @@ module.exports = { 'org.labkey.api.security.permissions.ReadPermission', ], path: './src/client/pages/updateRackStatus' + }, + { + name: "adoptionDataEntry", + title: "Adoption Form", + permissionClasses: [ + 'org.labkey.api.security.permissions.ReadPermission', + 'org.labkey.cageui.security.permissions.CageUIAdoptionsPermission' + ], + path: './src/client/pages/adoptionDataEntry' } ] }; diff --git a/CageUI/src/client/pages/adoptionDataEntry/AdoptionDataEntry.tsx b/CageUI/src/client/pages/adoptionDataEntry/AdoptionDataEntry.tsx new file mode 100644 index 000000000..d232d6326 --- /dev/null +++ b/CageUI/src/client/pages/adoptionDataEntry/AdoptionDataEntry.tsx @@ -0,0 +1,85 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +import * as React from 'react'; +import { FC, useEffect, useState } from 'react'; +import '../../cageui.scss'; +import { labkeyActionSelectWithPromise } from '../../api/labkeyActions'; +import { AdoptionForm } from '../../components/adoptionDataEntry/AdoptionForm'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { ActionURL, Filter, Query } from '@labkey/api'; +import { AdoptionData, AdoptionResult, AdoptionStatus } from '../../types/adoptionFormTypes'; +import dayjs from 'dayjs'; + + +export const AdoptionDataEntry: FC = () => { + const prevFormObjId = ActionURL.getParameter('objectid'); + const [prevFormData, setPrevFormData] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if(!prevFormObjId) { + setIsLoading(false); + return; + } + const config: Query.SelectRowsOptions = { + schemaName: 'study', + queryName: 'adoptions', + columns: ['Id', 'objectid', 'date', 'dam', 'sire', 'result/value', 'result/title', 'type/value', 'type/title'], + filterArray: [Filter.create('objectid', prevFormObjId, Filter.Types.EQUAL)] + }; + + labkeyActionSelectWithPromise(config).then(result => { + if (result.rowCount === 1) { + const res = result.rows[0]; + const adoptionData: AdoptionData = { + dam: res.dam, + sire: res.sire, + date: dayjs(res.date), + id: res.Id, + objectid: res.objectid, + result: { + label: res['result/title'] as keyof typeof AdoptionResult, + value: parseInt(res['result/value']) + }, + type: { + label: res['type/title'] as keyof typeof AdoptionStatus, + value: parseInt(res['type/value']) + } + }; + setPrevFormData(adoptionData); + setIsLoading(false); + }else{ + setIsLoading(false); + } + }).catch(err => { + console.error('Error fetching alive at center animals', err); + setIsLoading(false); + }); + }, []); + + return( + !isLoading && + +
+ +
+
+ ) +}; \ No newline at end of file diff --git a/CageUI/src/client/pages/adoptionDataEntry/app.tsx b/CageUI/src/client/pages/adoptionDataEntry/app.tsx new file mode 100644 index 000000000..ce896bd1d --- /dev/null +++ b/CageUI/src/client/pages/adoptionDataEntry/app.tsx @@ -0,0 +1,30 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AdoptionDataEntry } from './AdoptionDataEntry'; + + +// Need to wait for container element to be available in labkey wrapper before render +window.addEventListener('DOMContentLoaded', (event) => { + + createRoot(document.getElementById('app')).render( + + ); +}); \ No newline at end of file diff --git a/CageUI/src/client/pages/adoptionDataEntry/dev.tsx b/CageUI/src/client/pages/adoptionDataEntry/dev.tsx new file mode 100644 index 000000000..b449ef975 --- /dev/null +++ b/CageUI/src/client/pages/adoptionDataEntry/dev.tsx @@ -0,0 +1,29 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AdoptionDataEntry } from './AdoptionDataEntry'; + +const render = () => { + createRoot(document.getElementById('app')).render( + + ); +}; + +render(); \ No newline at end of file diff --git a/CageUI/src/client/types/adoptionFormTypes.ts b/CageUI/src/client/types/adoptionFormTypes.ts new file mode 100644 index 000000000..914311ed6 --- /dev/null +++ b/CageUI/src/client/types/adoptionFormTypes.ts @@ -0,0 +1,41 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +import { Dayjs } from 'dayjs'; + +export interface AdoptionData { + objectid: string; + id: string; + date: Dayjs; + dam: string | null; + sire: string | null; + type: {label: keyof typeof AdoptionStatus, value: AdoptionStatus}; + result?: {label: keyof typeof AdoptionResult, value: AdoptionResult}; +} + +export enum AdoptionStatus { + Start, + End, + Pause, + Resume +} + +export enum AdoptionResult { + Success, + Failure +} \ No newline at end of file diff --git a/CageUI/src/org/labkey/cageui/CageUIController.java b/CageUI/src/org/labkey/cageui/CageUIController.java index 88a1deb5e..97d5ea89a 100644 --- a/CageUI/src/org/labkey/cageui/CageUIController.java +++ b/CageUI/src/org/labkey/cageui/CageUIController.java @@ -54,10 +54,16 @@ import org.labkey.api.view.HtmlView; import org.labkey.api.view.JspView; import org.labkey.api.view.NavTree; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.cageui.action.AdoptionDataForm; import org.labkey.cageui.action.BundledForms; import org.labkey.cageui.action.CagesForm; import org.labkey.cageui.action.RackTypesForm; import org.labkey.cageui.action.RacksForm; +import org.labkey.cageui.model.AdoptionData; +import org.labkey.cageui.model.AdoptionType; import org.labkey.cageui.model.Cage; import org.labkey.cageui.model.Manufacturer; import org.labkey.cageui.model.ModData; @@ -69,6 +75,8 @@ import org.labkey.cageui.model.RackTypes; import org.labkey.cageui.model.Room; import org.labkey.cageui.model.SessionLog; +import org.labkey.cageui.security.permissions.CageUIAdoptionsPermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission; @@ -80,15 +88,20 @@ import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; public class CageUIController extends SpringActionController { + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(CageUIController.class); public static final String NAME = "cageui"; @@ -115,6 +128,196 @@ public void addNavTrail(NavTree root) } } + @RequiresPermission(CageUIAdoptionsPermission.class) + public static class SubmitAdoptionFormAction extends MutatingApiAction + { + ArrayList _adoptionData; + + public ArrayList getAdoptionData() + { + return _adoptionData; + } + + public void setAdoptionData(ArrayList adoptionData) + { + _adoptionData = adoptionData; + } + + + @Override + public void validateForm(SimpleApiJsonForm form, Errors errors) + { + JSONObject json = form.getJsonObject(); + if (json == null) + { + errors.reject(ERROR_MSG, "Missing json parameter."); + return; + } + + JSONArray jsonTransferData = json.getJSONArray("adoptionData"); + ObjectMapper mapper = JsonUtil.createDefaultMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + try + { + TypeReference> typeRef = new TypeReference>() + { + }; + ArrayList adoptionDataList = mapper.readValue(jsonTransferData.toString(), typeRef); + setAdoptionData(adoptionDataList); + }catch (JsonProcessingException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + + // Validation on each individual row + for (AdoptionData row : getAdoptionData()){ + + if(row.getDam() == null && row.getSire() == null){ + errors.reject(ERROR_MSG, "Animal " + row.getId() + " must have a Sire or Dam."); + } + if(row.getSire() != null && row.getId().equals(row.getSire())){ + errors.reject(ERROR_MSG, "Infant cannot be the Sire."); + } + if(row.getDam() != null && row.getId().equals(row.getDam())) { + errors.reject(ERROR_MSG, "Infant cannot be the Dam."); + } + if(row.getSire() != null && row.getDam() != null && row.getSire().equals(row.getDam())){ + errors.reject(ERROR_MSG, "Sire and Dam cannot be the same."); + } + } + + + + Map> dataById = getAdoptionData().stream() + .collect(Collectors.groupingBy(AdoptionData::getId)); + + // validation cross referencing other rows + for (Map.Entry> entry : dataById.entrySet()) + { + String id = entry.getKey(); + + List newAdoptions = entry.getValue(); + newAdoptions.sort(Comparator.comparing(AdoptionData::getDate)); + + List existingAdoptions = CageUIManager.getAdoptionsForId(id, getUser(), getContainer()); + existingAdoptions.sort(Comparator.comparing(AdoptionDataForm::getDate)); + + AdoptionDataForm lastAdoption = existingAdoptions.isEmpty() ? null : existingAdoptions.get(existingAdoptions.size() - 1); + String expectedDam = lastAdoption != null ? lastAdoption.getDam() : null; + + for (AdoptionData newAdoption : newAdoptions) + { + AdoptionType newType = AdoptionType.fromInt(newAdoption.getType().getValue()); + AdoptionType lastType = lastAdoption != null ? AdoptionType.fromInt(lastAdoption.getType()) : null; + + // Type validation + if (newType == AdoptionType.START) + { + if (lastType != null && lastType != AdoptionType.END) + { + errors.reject(ERROR_MSG, "Animal " + id + " already has an ongoing adoption. Must end previous adoption before starting a new one."); + } + } + else if (newType == AdoptionType.PAUSE) + { + if (lastType != AdoptionType.START && lastType != AdoptionType.RESUME) + { + errors.reject(ERROR_MSG, "Animal " + id + " can only be paused if it is currently started or resumed."); + } + } + else if (newType == AdoptionType.RESUME) + { + if (lastType != AdoptionType.PAUSE) + { + errors.reject(ERROR_MSG, "Animal " + id + " can only be resumed if it is currently paused."); + } + } + else if (newType == AdoptionType.END) + { + if (lastType == AdoptionType.END) + { + errors.reject(ERROR_MSG, "Animal " + id + " adoption has already ended."); + } + } + + // Dam validation + if (expectedDam == null) + { + expectedDam = newAdoption.getDam(); + } + else if (!expectedDam.equals(newAdoption.getDam())) + { + errors.reject(ERROR_MSG, "Dam ID for animal " + id + " must be consistent across adoptions. Expected: " + expectedDam + ", Found: " + newAdoption.getDam()); + } + + // Date validation + if (lastAdoption != null && !newAdoption.getDate().after(lastAdoption.getDate())) + { + errors.reject(ERROR_MSG, "Date for animal " + id + " must be after the previous adoption entry's date."); + } + + // Update last adoption for next iteration + AdoptionDataForm currentAsForm = new AdoptionDataForm(); + currentAsForm.setId(newAdoption.getId()); + currentAsForm.setType(newAdoption.getType().getValue()); + currentAsForm.setDate(newAdoption.getDate()); + currentAsForm.setDam(newAdoption.getDam()); + lastAdoption = currentAsForm; + } + } + } + + @Override + public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception + { + BatchValidationException batchErrors = new BatchValidationException(); + ApiSimpleResponse response = new ApiSimpleResponse(); + UserSchema studySchema = QueryService.get().getUserSchema(getUser(), getContainer(), "study"); + ArrayList finalForm = new ArrayList(); + + for(AdoptionData row : getAdoptionData()){ + AdoptionDataForm finalRow = new AdoptionDataForm(); + finalRow.setId(row.getId()); + finalRow.setDate(row.getDate()); + finalRow.setDam(row.getDam()); + finalRow.setType(row.getType().getValue()); + if(row.getResult() != null){ + finalRow.setResult(row.getResult().getValue()); + } + finalForm.add(finalRow); + } + + TableInfo studyAdoptionsTable = studySchema.getTable("adoptions"); + QueryUpdateService studyAdoptionsQus = studyAdoptionsTable.getUpdateService(); + if (studyAdoptionsQus == null) + { + throw new IllegalStateException(studyAdoptionsTable.getName() + " query update service"); + } + + try (DbScope.Transaction tx = CageUISchema.getInstance().getSchema().getScope().ensureTransaction()) + { + List> adoptionMapList = CageUIManager.get().convertToMapList(finalForm); + + studyAdoptionsQus.insertRows(getUser(), getContainer(), adoptionMapList, batchErrors, null, null); + + if (batchErrors.hasErrors()) + { + response.put("success", false); + response.put("errors", batchErrors); + return response; + } + tx.commit(); + response.put("success", true); + } + catch (QueryUpdateServiceException | BatchValidationException | DuplicateKeyException | RuntimeException | + SQLException e) + { + throw new ValidationException(e.getMessage()); + } + return response; + } + } + @RequiresPermission(CageUIRoomModifierPermission.class) public static class UpdateRackConditionStatusAction extends MutatingApiAction { diff --git a/CageUI/src/org/labkey/cageui/CageUIManager.java b/CageUI/src/org/labkey/cageui/CageUIManager.java index bb4d80cd5..020d59ecd 100644 --- a/CageUI/src/org/labkey/cageui/CageUIManager.java +++ b/CageUI/src/org/labkey/cageui/CageUIManager.java @@ -42,6 +42,7 @@ import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; import org.labkey.api.util.JsonUtil; +import org.labkey.cageui.action.AdoptionDataForm; import org.labkey.cageui.action.AllHistoryForm; import org.labkey.cageui.action.BundledForms; import org.labkey.cageui.action.CageModificationHistoryForm; @@ -510,6 +511,24 @@ public static ArrayList getRacksInRoom(String room) return racksForm; } + public static ArrayList getAdoptionsForId(String id, User user, Container container) + { + + //TableInfo table = getRealTableForDataset(container, "adoptions"); + UserSchema studySchema = QueryService.get().getUserSchema(user, container, "study"); + TableInfo table = studySchema.getTable("adoptions"); + + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(FieldKey.fromString("Id"), id, CompareType.EQUAL); + TableSelector selector = new TableSelector(table, filter, null); + + ObjectMapper mapper = JsonUtil.createDefaultMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + TypeReference> typeRef = new TypeReference>() {}; + ArrayList form = mapper.convertValue(selector.getMapArray(), typeRef); + return form; + } + // ends an all history row public static AllHistoryForm endPreviousAllHistory(String room, Date endDate) diff --git a/CageUI/src/org/labkey/cageui/action/AdoptionDataForm.java b/CageUI/src/org/labkey/cageui/action/AdoptionDataForm.java new file mode 100644 index 000000000..4bce301cd --- /dev/null +++ b/CageUI/src/org/labkey/cageui/action/AdoptionDataForm.java @@ -0,0 +1,85 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.action; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.util.Date; + +public class AdoptionDataForm +{ + @JsonAlias({"id", "Id"}) + private String id; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss.SSS") + private Date date; + private String dam; + private Integer type; + private Integer result; + + public String getId() + { + return id; + } + + public void setId(String id) + { + this.id = id; + } + + public Date getDate() + { + return date; + } + + public void setDate(Date date) + { + this.date = date; + } + + public String getDam() + { + return dam; + } + + public void setDam(String dam) + { + this.dam = dam; + } + + public Integer getType() + { + return type; + } + + public void setType(Integer type) + { + this.type = type; + } + + public Integer getResult() + { + return result; + } + + public void setResult(Integer result) + { + this.result = result; + } +} diff --git a/CageUI/src/org/labkey/cageui/model/AdoptionData.java b/CageUI/src/org/labkey/cageui/model/AdoptionData.java new file mode 100644 index 000000000..87a92b309 --- /dev/null +++ b/CageUI/src/org/labkey/cageui/model/AdoptionData.java @@ -0,0 +1,105 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.model; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.util.Date; + +public class AdoptionData +{ + private String id; + private String objectid; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private Date date; + private String dam; + private String sire; + private Option type; + private Option result; + + public String getId() + { + return this.id; + } + + public void setId(String id) + { + this.id = id; + } + + public String getObjectid() + { + return this.objectid; + } + + public void setObjectid(String objectid) + { + this.objectid = objectid; + } + + public Date getDate() + { + return this.date; + } + + public void setDate(Date date) + { + this.date = date; + } + + public String getDam() + { + return this.dam; + } + + public void setDam(String dam) + { + this.dam = dam; + } + + public Option getType() + { + return this.type; + } + + public void setType(Option type) + { + this.type = type; + } + + public Option getResult() + { + return this.result; + } + + public void setResult(Option result) + { + this.result = result; + } + + public String getSire() + { + return sire; + } + + public void setSire(String sire) + { + this.sire = sire; + } +} diff --git a/CageUI/src/org/labkey/cageui/model/AdoptionType.java b/CageUI/src/org/labkey/cageui/model/AdoptionType.java new file mode 100644 index 000000000..9bf4163bf --- /dev/null +++ b/CageUI/src/org/labkey/cageui/model/AdoptionType.java @@ -0,0 +1,51 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.model; + +public enum AdoptionType +{ + START(0), + END(1), + PAUSE(2), + RESUME(3); + + private final int _value; + + AdoptionType(int value) + { + _value = value; + } + + public int getValue() + { + return _value; + } + + public static AdoptionType fromInt(int value) + { + for (AdoptionType type : AdoptionType.values()) + { + if (type.getValue() == value) + { + return type; + } + } + return null; + } +} diff --git a/CageUI/src/org/labkey/cageui/model/Option.java b/CageUI/src/org/labkey/cageui/model/Option.java new file mode 100644 index 000000000..aebc4768a --- /dev/null +++ b/CageUI/src/org/labkey/cageui/model/Option.java @@ -0,0 +1,78 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.model; + +import java.io.Serializable; +import java.util.Objects; + +public class Option implements Serializable { + private String label; + private T value; + + // Constructor + public Option() {} + + public Option(String label, T value) { + this.label = label; + this.value = value; + } + + // Getters and setters + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + this.value = value; + } + + @Override + public String toString() { + return "Option{" + + "label='" + label + '\'' + + ", value=" + value + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Option that = (Option) o; + + if (!Objects.equals(label, that.label)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + int result = label != null ? label.hashCode() : 0; + result = 31 * result + (value != null ? value.hashCode() : 0); + return result; + } +} \ No newline at end of file diff --git a/CageUI/src/org/labkey/cageui/security/permissions/CageUIAdoptionsPermission.java b/CageUI/src/org/labkey/cageui/security/permissions/CageUIAdoptionsPermission.java new file mode 100644 index 000000000..e61a826a7 --- /dev/null +++ b/CageUI/src/org/labkey/cageui/security/permissions/CageUIAdoptionsPermission.java @@ -0,0 +1,30 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.security.permissions; + +import org.labkey.api.security.permissions.AbstractPermission; + +public class CageUIAdoptionsPermission extends AbstractPermission +{ + public CageUIAdoptionsPermission() + { + super("Cage UI Adoptions", + "This permission allows the user access to adoptions table and submitted/editing data"); + } +} \ No newline at end of file diff --git a/CageUI/src/org/labkey/cageui/security/roles/CageUIAdoptionsRole.java b/CageUI/src/org/labkey/cageui/security/roles/CageUIAdoptionsRole.java new file mode 100644 index 000000000..1ee1683ca --- /dev/null +++ b/CageUI/src/org/labkey/cageui/security/roles/CageUIAdoptionsRole.java @@ -0,0 +1,41 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.security.roles; + +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.roles.AbstractRole; +import org.labkey.cageui.CageUIModule; +import org.labkey.cageui.security.permissions.CageUIAdoptionsPermission; + +public class CageUIAdoptionsRole extends AbstractRole +{ + + public CageUIAdoptionsRole() + { + this("Cage UI Adoptions", + "Adoptions role for Cage UI", + CageUIAdoptionsPermission.class + ); + } + + protected CageUIAdoptionsRole(String name, String description, Class... perms) + { + super(name, description, CageUIModule.class, perms); + } +} \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/adoptions.query.xml b/WNPRC_EHR/resources/queries/study/adoptions.query.xml new file mode 100644 index 000000000..2ee43afab --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/adoptions.query.xml @@ -0,0 +1,64 @@ + + + + + + + + /cageui/adoptionDataEntry.view + + /cageui/adoptionDataEntry.view?objectid=${objectid} + + + + Infant Id + + + yyyy-MM-dd HH:mm + Date + + + + ehr_lookups + adoption_status + value + title + + + + + ehr_lookups + adoption_results + value + title + + + + Foster Dam + /ehr/participantView.view?participantId=${dam} + + + Foster Sire + /ehr/participantView.view?participantId=${sire} + + +
+
+
+
\ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/adoptions/.qview.xml b/WNPRC_EHR/resources/queries/study/adoptions/.qview.xml new file mode 100644 index 000000000..d17db41a9 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/adoptions/.qview.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/WNPRC_EHR/resources/queries/study/adoptionsOngoing.sql b/WNPRC_EHR/resources/queries/study/adoptionsOngoing.sql new file mode 100644 index 000000000..de5c5eda5 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/adoptionsOngoing.sql @@ -0,0 +1,33 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +SELECT + a.Id, + a.date, + a.type, + a.result, + a.dam +FROM study.adoptions a +WHERE (a.type = '0') -- start + AND NOT EXISTS ( + SELECT 1 + FROM study.adoptions a2 + WHERE a.Id = a2.Id + AND a2.date > a.date + AND (a2.type = '1') -- end + ) diff --git a/WNPRC_EHR/resources/queries/study/adoptionsSuccess.sql b/WNPRC_EHR/resources/queries/study/adoptionsSuccess.sql new file mode 100644 index 000000000..1ca424e84 --- /dev/null +++ b/WNPRC_EHR/resources/queries/study/adoptionsSuccess.sql @@ -0,0 +1,40 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +SELECT + a_start.dam, + a_start.Id, + a_start.date AS start_date, + a_end.date AS end_date, + timestampdiff('SQL_TSI_DAY', a_start.date, a_end.date) AS days_adopted, + (SELECT COUNT(*) FROM study.adoptions a_sub WHERE a_sub.dam = a_start.dam AND a_sub.type = '1' AND a_sub.result = '0' AND a_sub.date <= a_end.date) AS total_adoptions_for_dam +FROM study.adoptions a_start +JOIN study.adoptions a_end ON a_start.Id = a_end.Id AND a_start.dam = a_end.dam +WHERE a_start.type = '0' + AND a_end.type = '1' + AND a_end.result = '0' + AND a_end.date = ( + SELECT MIN(a2.date) + FROM study.adoptions a2 + WHERE a2.Id = a_start.Id + AND a2.dam = a_start.dam + AND a2.type = '1' + AND a2.date > a_start.date + ) + +