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 @@
+
+
+
+
+
+
+
+ Rooms
+
+
+ string
+
+
+
+
+
+
+
+
\ 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
+
+
+
+