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 aaf6b90bf..2518344c7 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 +
+
+
+