diff --git a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql new file mode 100644 index 000000000..e995899e5 --- /dev/null +++ b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql @@ -0,0 +1,57 @@ +/* + * + * * 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. + * + */ + +INSERT INTO ehr_lookups.lookup_sets (setname, label, description, keyField, container) +select 'cageui_svg_urls' as setname, + 'SVG Urls Field Values' as label, + 'List of URLS for room items' 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, 'cage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'pen' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'tempCage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'playCage' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'roomDivider' as value, '/cageui/static/roomDivider.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'drain' as value, '/cageui/static/drain.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'door' as value, '/cageui/static/door.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateClosed' as value, '/cageui/static/gateClosed.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateOpen' as value, '/cageui/static/gateOpen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'top' as value, '/cageui/static/top.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'bottom' as value, '/cageui/static/bottom.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; diff --git a/CageUI/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index a64e32c8e..51a97407f 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -16,112 +16,119 @@ - * limitations under the License. - */ --> - - - + + - - - - - - + + - - - - - + + + + - - - - - + - - + - - - - - - - + + + - Solid Divider + Solid Divider - - Protected Contact Divider + Protected Contact Divider - - Visual Contact Divider + Visual Contact Divider - - Privacy Divider + Privacy Divider - - Standard Floor + Standard Floor - - Mesh Floor + Mesh Floor - - Mesh Floor x2 + Mesh Floor x2 - Extension - - + C-Tunnel - - - - + - - - + - Social Panel Divider + Social Panel Divider - - + - Restraint + Restraint - - - + - Window Blind + Window Blind - - - + + + + + + Locked Divider + \ No newline at end of file diff --git a/CageUI/src/client/cageui.scss b/CageUI/src/client/cageui.scss index 8f9f57618..a3b6bc49a 100644 --- a/CageUI/src/client/cageui.scss +++ b/CageUI/src/client/cageui.scss @@ -1148,7 +1148,7 @@ .room-list-items { overflow-y: auto; - padding: 5px; + padding: 5px 15px 5px 5px; } .arrow { @@ -1166,13 +1166,19 @@ border: 1px solid black; } +.room-dir-header-container { + display: flex; + align-items: center; + justify-content: space-between; +} + .room-dir-header { cursor: pointer; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; font-size: x-large; + flex-grow: 1; } .room-dir-room-obj { @@ -1180,14 +1186,20 @@ border-bottom: 1px solid lightgrey; } +.room-dir-rack-obj-container { + display: flex; + align-items: center; + justify-content: space-between; +} + .room-dir-rack-obj { cursor: pointer; font-size: large; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; margin: 15px 10px 15px 5px; + flex-grow: 1; } .room-dir-cage-obj { @@ -1199,12 +1211,12 @@ margin: 15px 10px 15px 5px; } -.room-dir-header.open .arrow { +.room-dir-header-container.open .arrow { transform: rotate(135deg); } -.room-dir-rack-obj.open .arrow { +.room-dir-rack-obj-container.open .arrow { transform: rotate(135deg); } diff --git a/CageUI/src/client/components/LoadingScreen.tsx b/CageUI/src/client/components/LoadingScreen.tsx index 821ffdc42..d7c2d91ad 100644 --- a/CageUI/src/client/components/LoadingScreen.tsx +++ b/CageUI/src/client/components/LoadingScreen.tsx @@ -22,11 +22,12 @@ import { createPortal } from 'react-dom'; interface LoadingScreenProps { isVisible: boolean; + message: string; targetElement?: HTMLElement | null; } export const LoadingScreen: FC = (props) => { - const {isVisible, targetElement} = props; + const {isVisible, message, targetElement} = props; const [container, setContainer] = useState(null); @@ -44,7 +45,7 @@ export const LoadingScreen: FC = (props) => {
-

Saving...

+

{message}

, container diff --git a/CageUI/src/client/components/home/RoomContent.tsx b/CageUI/src/client/components/home/RoomContent.tsx index 750d30bdc..e057356fe 100644 --- a/CageUI/src/client/components/home/RoomContent.tsx +++ b/CageUI/src/client/components/home/RoomContent.tsx @@ -24,9 +24,10 @@ import { CageViewContent } from './cageView/CageViewContent'; import { RackViewContent } from './rackView/RackViewContent'; import { HomeViewContent } from './HomeViewContent'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; +import { LoadingScreen } from '../LoadingScreen'; export const RoomContent: FC = () => { - const {selectedPage} = useHomeNavigationContext(); + const {selectedPage, isNavLoading} = useHomeNavigationContext(); const renderContent = () => { switch (selectedPage?.selected) { @@ -43,7 +44,12 @@ export const RoomContent: FC = () => { return (
- {renderContent()} + + {!isNavLoading && renderContent()}
); }; \ No newline at end of file diff --git a/CageUI/src/client/components/home/RoomList.tsx b/CageUI/src/client/components/home/RoomList.tsx index a6a58c633..98bbed5f9 100644 --- a/CageUI/src/client/components/home/RoomList.tsx +++ b/CageUI/src/client/components/home/RoomList.tsx @@ -17,26 +17,29 @@ */ import * as React from 'react'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import '../../cageui.scss'; import { Room } from '../../types/typings'; import { ExpandedRooms, ListCage, ListRack, ListRoom } from '../../types/homeTypes'; import { labkeyActionSelectWithPromise } from '../../api/labkeyActions'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; -import { Filter } from '@labkey/api'; +import { ActionURL, Filter } from '@labkey/api'; export const RoomList: FC = () => { - const {navigateTo} = useHomeNavigationContext(); + const {navigateTo, selectedPage, setIsNavLoading} = useHomeNavigationContext(); // keeps track of which rooms have already been fetched from layout_history const [expandedRooms, setExpandedRooms] = useState({}); - const [expandedRacks, setExpandedRacks] = useState([]); + const [expandedRacks, setExpandedRacks] = useState>({}); const [allRooms, setAllRooms] = useState([]); // Stores all items fetched on load const [visibleRooms, setVisibleRooms] = useState([]); // Items currently visible const [searchQuery, setSearchQuery] = useState(''); + const roomRefs = useRef>({}); + const listContainerRef = useRef(null); + const handleSearch = (e) => { setSearchQuery(e.target.value); }; @@ -120,6 +123,18 @@ export const RoomList: FC = () => { }); }); }); + + // Sort cages within each rack and then sort racks by their first cage + tempRacks.forEach((rack) => { + rack.cages.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + }); + tempRacks.sort((a, b) => { + if (a.cages.length > 0 && b.cages.length > 0) { + return a.cages[0].name.localeCompare(b.cages[0].name, undefined, { numeric: true }); + } + return 0; + }); + return { ...prevRoom, racks: tempRacks, @@ -144,15 +159,63 @@ export const RoomList: FC = () => { })); }; + // Auto-expand and scroll based on URL parameters + useEffect(() => { + const roomName = ActionURL.getParameter("room"); + const rackId = ActionURL.getParameter("rack"); + + if (roomName) { + if (!expandedRooms[roomName]) { + toggleExpandRoom(roomName); + } + + if (rackId) { + const rackKey = `${roomName}_${rackId}`; + if (!expandedRacks[rackKey]) { + setExpandedRacks(prev => ({ + ...prev, + [rackKey]: true + })); + } + } + + // Scroll room into view + if (roomRefs.current[roomName] && listContainerRef.current) { + const container = listContainerRef.current; + const element = roomRefs.current[roomName]; + + // Use a short timeout to ensure the DOM has updated (expanded) before we calculate the offset + setTimeout(() => { + if (element && container) { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + // elementRect.top is the distance from viewport top to element top + // containerRect.top is the distance from viewport top to container top + // relativeTop is the distance from container top to element top within the scrollable area + const relativeTop = elementRect.top - containerRect.top + container.scrollTop; + + container.scrollTo({ + top: relativeTop, + behavior: 'auto' + }); + } + }, 100); + } + } + }, [selectedPage, allRooms, visibleRooms]); + const handleRoomClick = (room: ListRoom) => { + setIsNavLoading(true); navigateTo({selected: 'Room', room: room.name}) }; const handleRackClick = (room: ListRoom, rack: ListRack) => { + setIsNavLoading(true); navigateTo({selected: 'Rack', room: room.name, rack: rack.id}); }; const handleCageClick = (room: ListRoom, rack: ListRack, cage: ListCage) => { + setIsNavLoading(true); navigateTo({selected: 'Cage', room: room.name, rack: rack.id, cage: cage.id}); }; @@ -165,25 +228,35 @@ export const RoomList: FC = () => { className={'room-search'} onChange={handleSearch} /> -
    +
      {visibleRooms.map((room, index) => ( -
      -
      handleRoomClick(room)} - className={`room-dir-header ${expandedRooms[room.name] ? 'open' : ''}`} - > - {room.name} +
      { + if (el) roomRefs.current[room.name] = el; + }} + > +
      +
      handleRoomClick(room)} + className={`room-dir-header`} + > + {room.name} +
      toggleExpandRoom(room.name)}>
      {expandedRooms[room.name] && (
        {room?.racks?.map((rack) => (
      • -
        handleRackClick(room, rack)} - className={`room-dir-rack-obj ${expandedRacks[`${room.name}_${rack.id}`] ? 'open' : ''}`} - > - {rack.name} +
        +
        handleRackClick(room, rack)} + className={`room-dir-rack-obj`} + > + {rack.name} +
        toggleExpandRack(room.name, rack.id)}>
        diff --git a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx index 3fa788b35..821258ec0 100644 --- a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx +++ b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx @@ -147,7 +147,7 @@ export const ChangeRackPopup: FC = (props) => { //navigateTo({selected: 'Room', room: selectedRoom.name}); window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-home', + 'home', ActionURL.getContainer(), {room: res.roomName, rack: res.rack}); @@ -167,6 +167,7 @@ export const ChangeRackPopup: FC = (props) => { {isSaving && } diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index 6d8ce82d3..c206d5d86 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -100,7 +100,6 @@ export const CagePopup: FC = (props) => { // This submission updates the room mods with the current selections. const handleSaveMods = () => { - console.log("SaveMods: ", currCageMods); validateAndApplyDefaults(currCageMods).then((res) => { const result = saveCageMods(prevCage, res); diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index a5931267d..cadb12a8e 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -122,8 +122,12 @@ export const RoomLayout: FC = (props) => { return (
        - {isSaving && }
        {showChangesMenu && diff --git a/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx index 748a58de8..40dad79c8 100644 --- a/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx +++ b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx @@ -43,10 +43,6 @@ export const RoomObjectPopup: FC = (props) => { const [prevRoomObjId, setPrevRoomObjId] = useState((selectedObj as RoomObject).itemId); const menuRef = useRef(null); - useEffect(() => { - console.log('roomObj: ', roomObj); - }, [roomObj]); - useEffect(() => { // Check if the click was outside the menu const handleClickOutside = (event) => { diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index b83ce70f0..7f6b165f6 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -689,8 +689,9 @@ const Editor: FC = ({roomSize}) => { } }); // loads grid with new room - addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag); - setReloadRoom(null); + addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag).then(() => { + setReloadRoom(null); + }); }, [reloadRoom]); // Effect attaches an observer to the border_template svg. after it is injected into the dom it will run @@ -750,8 +751,8 @@ const Editor: FC = ({roomSize}) => { if (loadTemplate) { window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-editLayout', - ActionURL.getContainer(), + 'editLayout', + ActionURL.getController(), {room: localRoom.name} ); } @@ -874,6 +875,7 @@ const Editor: FC = ({roomSize}) => { {startSaving && } diff --git a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx index 31604f82d..9990e64c3 100644 --- a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx +++ b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx @@ -17,13 +17,13 @@ */ import * as React from 'react'; -import { FC, ReactElement, useEffect, useRef } from 'react'; +import { FC, ReactElement, useEffect, useRef, useState } from 'react'; import '../../cageui.scss'; import { Button } from 'react-bootstrap'; import { parseRoomItemType, stringToRoomItem } from '../../utils/helpers'; import { Cage, - DefaultRackTypes, + DefaultRackTypes, Rack, RackGroup, RackStringType, RackTypes, RoomItemType, @@ -31,6 +31,8 @@ import { RoomObjectTypes } from '../../types/typings'; import { SelectedObj } from '../../types/layoutEditorTypes'; +import { useLayoutEditorContext } from '../../context/LayoutEditorContextManager'; +import { findCageInGroup } from '../../utils/LayoutEditorHelpers'; interface Option { label: string; @@ -64,23 +66,19 @@ export const EditorContextMenu: FC = (props) => { type } = props; + const {localRoom, unmergeRacks} = useLayoutEditorContext(); const menuRef = useRef(null); - // Delete object for room objects - const handleDeleteObject = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete(); - }; + const [selectedRack, setSelectedRack] = useState(); + const [selectedRackGroup, setSelectedRackGroup] = useState(); - // Delete cage and rack for caging units - const handleDeleteCage = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete('cage'); - }; - const handleDeleteRack = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete('rack'); - }; + useEffect(() => { + if(selectedObj.selectionType === 'cage'){ + const {rack, rackGroup} = findCageInGroup((selectedObj as Cage).svgId, localRoom.rackGroups); + setSelectedRack(rack); + setSelectedRackGroup(rackGroup); + } + }, [selectedObj]); useEffect(() => { const handleClickOutside = (event) => { @@ -136,12 +134,35 @@ export const EditorContextMenu: FC = (props) => { menu.style.top = `${adjustedTop}px`; }, [ctxMenuStyle.display, ctxMenuStyle.left, ctxMenuStyle.top]); + + // Delete object for room objects + const handleDeleteObject = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete(); + }; + + // Delete cage and rack for caging units + const handleDeleteCage = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete('cage'); + }; + const handleDeleteRack = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete('rack'); + }; + + const handleUnmergeRack = (e: React.MouseEvent) => { + e.stopPropagation(); + unmergeRacks(selectedRackGroup, selectedRack); + closeMenu(); + } + return (
        {menuItems && menuItems.map((item, index) => { @@ -186,6 +207,15 @@ export const EditorContextMenu: FC = (props) => { > Delete Rack + + {(selectedRackGroup && selectedRackGroup.racks.length > 1) && + + }
        }
        diff --git a/CageUI/src/client/context/HomeNavigationContextManager.tsx b/CageUI/src/client/context/HomeNavigationContextManager.tsx index eace2551b..416fa3392 100644 --- a/CageUI/src/client/context/HomeNavigationContextManager.tsx +++ b/CageUI/src/client/context/HomeNavigationContextManager.tsx @@ -58,6 +58,8 @@ export const HomeNavigationContextProvider: FC = ({u const [selectedRack, setSelectedRack] = useState(null); const [selectedCage, setSelectedCage] = useState(null); + const [isNavLoading, setIsNavLoading] = useState(false); + useEffect(() => { setSelectedLocalRoom(selectedRoom); }, [selectedRoom]); @@ -122,11 +124,14 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(null); setSelectedRack(null); setSelectedCage(null); + setIsNavLoading(false); break; case 'Room': if (page.room) { - loadRoomData(page.room); + loadRoomData(page.room).then((newRoom) => { + setIsNavLoading(false); + }); } break; @@ -138,11 +143,13 @@ export const HomeNavigationContextProvider: FC = ({u const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, newRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); }); } else { const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, selectedRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); } } break; @@ -160,6 +167,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); }); } else { const { @@ -170,6 +178,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); } } break; @@ -232,6 +241,8 @@ export const HomeNavigationContextProvider: FC = ({u navigateTo, setSelectedLocalRoom, userProfile, + isNavLoading, + setIsNavLoading }}> {children} diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index 607b9115c..58f6ba7ad 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -58,11 +58,14 @@ import { getTranslation, isRackEnum, showLayoutEditorError, + checkAdjacent } from '../utils/LayoutEditorHelpers'; import * as d3 from 'd3'; import { + cageDirectionToModLocation, generateCageId, generateUUID, + getAdjLocation, getNextDefaultRackId, getSvgSize, parseLongId, @@ -289,6 +292,15 @@ export const LayoutEditorContextProvider: FC = ({children, p }); }; + const getNewGroupId = () => { + const newId = nextAvailGroup; + setNextAvailGroup(prevState => { + const nextId = parseLongId(prevState) + 1; + return `rack-group-${nextId}` as GroupId; + }); + return newId; + } + // This only adds default racks/cages to the layout, it is not used in loading in previous layouts const addRack = async (id: number, x: number, y: number, newScale: number, rackType: RackTypes): Promise => { const newCageNum: CageNumber = `${roomItemToString(rackType) as RackStringType}-${getNextCageNum(roomItemToString(rackType) as RackStringType)}`; @@ -367,7 +379,7 @@ export const LayoutEditorContextProvider: FC = ({children, p const newRackGroup: RackGroup = { selectionType: 'rackGroup', - groupId: nextAvailGroup, + groupId: getNewGroupId(), racks: [newRack], rotation: GroupRotation.Quarter, x: x, @@ -375,10 +387,6 @@ export const LayoutEditorContextProvider: FC = ({children, p scale: newScale, }; - setNextAvailGroup(prevState => { - const nextId = parseLongId(prevState) + 1; - return `rack-group-${nextId}` as GroupId; - }); setLocalRoom(prevRoom => ({ ...prevRoom, rackGroups: [...prevRoom.rackGroups, newRackGroup] @@ -844,7 +852,6 @@ export const LayoutEditorContextProvider: FC = ({children, p // 6. Split groups based on components let finalGroups = updatedGroups.filter(g => g.groupId !== location.rackGroup.groupId); - let nextGroupId = nextAvailGroup; // In the group splitting logic: if (components.size > 1) { @@ -948,15 +955,11 @@ export const LayoutEditorContextProvider: FC = ({children, p finalGroups.push({ ...affectedGroup, - groupId: nextGroupId, + groupId: getNewGroupId(), x: minX, y: minY, racks: newRacks }); - - // Update next group ID - const nextIdNum = parseInt(nextGroupId.split('-')[2]) + 1; - nextGroupId = `rack-group-${nextIdNum}` as GroupId; } } else { // No splitting needed, keep the modified group @@ -964,7 +967,6 @@ export const LayoutEditorContextProvider: FC = ({children, p } // 7. Update state - setNextAvailGroup(nextGroupId); setLocalRoom(prev => ({ ...prev, rackGroups: finalGroups @@ -1137,6 +1139,111 @@ export const LayoutEditorContextProvider: FC = ({children, p setCageNumChange({before: numBefore, after: numAfter}); }; + /* + Effectively unconnects the selectedRack from any connections with other racks. It does this by removing it from + the current rack group and creating a new rack group for the selected rack. + */ + const unmergeRacks = (rackGroup: RackGroup, selectedRack: Rack) => { + const newRoom: Room = { ...localRoom }; + + // 1. Find the index of the rack group that contains the selected rack + const rackGroupIndex = newRoom.rackGroups.findIndex(group => + group.groupId === rackGroup.groupId + ); + + if (rackGroupIndex === -1) return; + + const removedModIds: string[] = []; + const otherRacks = rackGroup.racks.filter(r => r.objectId !== selectedRack.objectId); + + // Process modifications between selectedRack and other racks in the group + selectedRack.cages.forEach(selectedCage => { + const selectedCageLoc = getCageLoc(selectedCage.svgId, selectedCage.cageNum); + if (!selectedCageLoc) return; + + otherRacks.forEach(otherRack => { + otherRack.cages.forEach(otherCage => { + const otherCageLoc = getCageLoc(otherCage.svgId, otherCage.cageNum); + if (!otherCageLoc) return; + + const adjResult = checkAdjacent(otherCageLoc, selectedCageLoc, selectedCage.size, otherCage.size); + if (adjResult.isAdjacent) { + const location = cageDirectionToModLocation(adjResult.direction, rackGroup.rotation); + const adjLocation = getAdjLocation(location); + + // Remove from selectedCage + if (selectedCage.mods && selectedCage.mods[location]) { + selectedCage.mods[location].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + selectedCage.mods[location] = []; + } + + // Remove from otherCage + if (otherCage.mods && otherCage.mods[adjLocation]) { + otherCage.mods[adjLocation].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + otherCage.mods[adjLocation] = []; + } + } + }); + }); + }); + + // Remove collected modIds from room.mods + if (newRoom.mods) { + removedModIds.forEach(id => { + delete newRoom.mods[id]; + }); + } + + // 2. Create the updated original rack group (without the selected rack) + let updatedOriginalRacks = otherRacks; + let updatedOriginalGroup = { ...rackGroup }; + + if (updatedOriginalRacks.length > 0) { + // Normalize the original group: find the new top-left corner + const minX = Math.min(...updatedOriginalRacks.map(r => r.x)); + const minY = Math.min(...updatedOriginalRacks.map(r => r.y)); + + updatedOriginalGroup = { + ...rackGroup, + x: rackGroup.x + minX, + y: rackGroup.y + minY, + racks: updatedOriginalRacks.map(r => ({ + ...r, + x: r.x - minX, + y: r.y - minY + })) + }; + newRoom.rackGroups[rackGroupIndex] = updatedOriginalGroup; + } else { + // If no racks left, remove the group entirely + newRoom.rackGroups.splice(rackGroupIndex, 1); + } + + // 3. Create the new rack group for the unmerged rack + // The new group starts at the global position of the selected rack + const newRackGroup: RackGroup = { + ...rackGroup, + groupId: getNewGroupId(), + x: rackGroup.x + selectedRack.x, + y: rackGroup.y + selectedRack.y, + racks: [{ + ...selectedRack, + x: 0, // Reset local coordinates to 0,0 in the new group + y: 0 + }] + }; + + // 4. Update the room state + newRoom.rackGroups = [...newRoom.rackGroups, newRackGroup]; + + setLocalRoom(newRoom); + setReloadRoom(newRoom); + }; + const getNextCageNum = (rackType: RackStringType) => { const cages = unitLocs[rackType]; @@ -1211,7 +1318,8 @@ export const LayoutEditorContextProvider: FC = ({children, p user, getAdjCages, reloadRoom, - setReloadRoom + setReloadRoom, + unmergeRacks }}> {!isLoading ? children : null} diff --git a/CageUI/src/client/pages/home/RoomHome.tsx b/CageUI/src/client/pages/home/RoomHome.tsx index 372707cda..d99dcf000 100644 --- a/CageUI/src/client/pages/home/RoomHome.tsx +++ b/CageUI/src/client/pages/home/RoomHome.tsx @@ -22,7 +22,7 @@ import '../../cageui.scss'; import { RoomList } from '../../components/home/RoomList'; import { RoomNavbar } from '../../components/home/RoomNavbar'; import { RoomContent } from '../../components/home/RoomContent'; -import { HomeNavigationContextProvider } from '../../context/HomeNavigationContextManager'; +import { HomeNavigationContextProvider, useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; import { RoomContextProvider } from '../../context/RoomContextManager'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; @@ -45,7 +45,7 @@ export const RoomHome: FC = () => { return (user?.container && -
        +
        diff --git a/CageUI/src/client/types/homeNavigationContextTypes.ts b/CageUI/src/client/types/homeNavigationContextTypes.ts index 7f6d1a87d..816a746b8 100644 --- a/CageUI/src/client/types/homeNavigationContextTypes.ts +++ b/CageUI/src/client/types/homeNavigationContextTypes.ts @@ -33,4 +33,6 @@ export interface HomeNavigationContextType { selectedCage: Cage; navigateTo: (page: SelectedPage) => void; userProfile: GetUserPermissionsResponse; + setIsNavLoading: React.Dispatch>; + isNavLoading: boolean; } \ No newline at end of file diff --git a/CageUI/src/client/types/layoutEditorContextTypes.ts b/CageUI/src/client/types/layoutEditorContextTypes.ts index 74661d169..7fb6a245e 100644 --- a/CageUI/src/client/types/layoutEditorContextTypes.ts +++ b/CageUI/src/client/types/layoutEditorContextTypes.ts @@ -70,4 +70,5 @@ export interface LayoutContextType { getAdjCages: (cage: Cage, cageLoc: LocationCoords) => LocationCoords[]; reloadRoom: Room, setReloadRoom: React.Dispatch>, + unmergeRacks: (rackGroup: RackGroup, selectedRack: Rack) => void; } \ No newline at end of file diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index cd307d423..18076e486 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -69,6 +69,7 @@ export enum ModTypes { PCDivider = 'pcd', // protected contact VCDivider = 'vcd', // visual contact PrivacyDivider = 'pd', + LockedDivider = 'ld', NoDivider = 'nd', CTunnel = 'ct', Extension = 'ex', @@ -184,6 +185,9 @@ export type Modification = { export type ModRecord = Record; +export interface LoadedSvgs { + [key: RoomItemStringType]: SVGElement; +} export interface FetchRoomData { selectedSize: SelectorOptions; diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index d339357a0..0f8e4fa85 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -830,7 +830,7 @@ export function checkAdjacent(targetCage: LocationCoords, draggedCage: LocationC } } - return {isAdjacent: false, direction: '0'}; + return {isAdjacent: false, direction: null}; } //Offset for the top left corner of the layout, without doing this objects will randomly jump when dragging and placing diff --git a/CageUI/src/client/utils/constants.ts b/CageUI/src/client/utils/constants.ts index 8ef19e5cf..b28eef1e5 100644 --- a/CageUI/src/client/utils/constants.ts +++ b/CageUI/src/client/utils/constants.ts @@ -214,6 +214,20 @@ export const Modifications: ModRecord = { value: '4' }] }, + [ModTypes.LockedDivider]: { + name: 'Locked Divider', + svgIds: { + [ModLocations.Left]: LocationWithRotationMap[ModLocations.Left], + [ModLocations.Right]: LocationWithRotationMap[ModLocations.Right], + }, + styles: [{ + property: 'stroke', + value: '#ed1c24' + }, { + property: 'stroke-width', + value: '2' + }] + }, [ModTypes.NoDivider]: { name: 'No Divider', svgIds: { diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 58555b31f..fff87219e 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -19,6 +19,7 @@ import { AllHistoryData, Cage, + CageDirection, CageModification, CageModificationsType, CageMods, @@ -32,7 +33,7 @@ import { GroupId, GroupRotation, LayoutData, - LayoutHistoryData, + LayoutHistoryData, LoadedSvgs, ModData, ModLocations, ModTypes, @@ -74,7 +75,7 @@ import { setupEditCageEvent } from './LayoutEditorHelpers'; import { SelectDistinctOptions } from '@labkey/api/dist/labkey/query/SelectDistinctRows'; -import { selectDistinctRows } from '@labkey/components'; +import { selectDistinctRows, selectRows } from '@labkey/components'; import { CELL_SIZE, Modifications, roomSizeOptions, SVG_HEIGHT, SVG_WIDTH } from './constants'; import { ExtraContext, LayoutSaveResult } from '../types/layoutEditorTypes'; import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; @@ -495,11 +496,45 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) return prevRoomData; }; +const loadSvgs = async (): Promise => { + const loadedSvgs: LoadedSvgs = {}; + + const config: SelectRowsOptions = { + schemaName: "ehr_lookups", + queryName: "cageui_svg_urls", + columns: ["value", "title"] + } + + const res = await labkeyActionSelectWithPromise(config); + if(res.rowCount > 0){ + + // Create all promises first + const promises = res.rows.map(row => { + return d3.svg(`${ActionURL.getContextPath()}${row.title}`).then((d) => { + if(!loadedSvgs[row.value]){ // cage templates + loadedSvgs[row.value] = d.querySelector(`svg[id*=template]`); + } + if(!loadedSvgs[row.value]){ // room objects + loadedSvgs[row.value] = d.querySelector('svg'); + } + }); + }); + + // Wait for all promises to complete + await Promise.all(promises); + }else{ + console.error("Error finding cageUI Svgs") + } + + return loadedSvgs; +} + // Adds the svgs from the saved layouts to the DOM. Mode edit is version displayed in the layout editor and view is the one in the home views. // roomForMods is passed if the unitsToRender is not room but needs access to the room object. This is for loading mods. -export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { +export const addPrevRoomSvgs = async (user: GetUserPermissionsResponse, mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { let renderType: 'room' | 'group' | 'rack' | 'cage'; + const loadedSvgs: LoadedSvgs = await loadSvgs(); if ((unitsToRender as Room)?.rackGroups) { renderType = 'room'; @@ -553,16 +588,13 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .style('pointer-events', 'bounding-box'); // This is where the cage svg group is created. - rack.cages.forEach(async (cage) => { + rack.cages.forEach((cage) => { const cageGroup = rackGroup.append('g') .attr('id', cage.svgId) .attr('name', cage.cageNum) .attr('transform', `translate(${cage.x},${cage.y})`); - let unitSvg: SVGElement; - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${rackTypeString}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - }); + const unitSvg: SVGElement = loadedSvgs[rackTypeString].cloneNode(true) as SVGElement; // Only needed for layout editor to attach context menus const shape = d3.select(unitSvg); @@ -598,9 +630,9 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('id', group.groupId) .attr('class', 'draggable rack-group'); - group.racks.forEach(async rack => { + group.racks.forEach( rack => { // Use parent group as rackGroup if only 1 rack, otherwise create a new rack group - await createRackGroup(parentGroup, rack, isSingleRack, group.rotation); + createRackGroup(parentGroup, rack, isSingleRack, group.rotation); }); let groupX = renderType === 'room' ? group.x : group.racks[0].x; let groupY = renderType === 'room' ? group.y : group.racks[0].y; @@ -620,7 +652,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | }); // Render room objects - (unitsToRender as Room).objects.forEach(async (roomObj) => { + (unitsToRender as Room).objects.forEach( (roomObj) => { const wrapperGroup = layoutSvg.append('g') .attr('id', roomObj.itemId + '-wrapper') .attr('class', 'draggable room-obj') @@ -631,10 +663,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('id', roomObj.itemId) .attr('transform', `translate(0,0)`) - let objSvg: SVGElement; - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${roomItemToString(roomObj.type)}.svg`).then((d) => { - objSvg = d.querySelector('svg'); - }); + const objSvg: SVGElement = loadedSvgs[roomItemToString(roomObj.type)].cloneNode(true) as SVGElement; const shape = d3.select(objSvg) .classed('draggable', false) @@ -664,18 +693,15 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | const cageGroup = layoutSvg.append('g') .attr('id', cage.cageNum) .attr('transform', `translate(0,0)`); - let unitSvg: SVGElement; + const unitSvg: SVGElement = loadedSvgs[parseRoomItemType((unitsToRender as Cage).cageNum)].cloneNode(true) as SVGElement; - d3.svg(`${ActionURL.getContextPath()}/cageui/static/${parseRoomItemType((unitsToRender as Cage).cageNum)}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - const shape = d3.select(unitSvg); - (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; + const shape = d3.select(unitSvg); + (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; - if (mode === 'view') { - loadCageMods(cage, shape, rackGroup.rotation); - } - cageGroup.append(() => shape.node()); - }); + if (mode === 'view') { + loadCageMods(cage, shape, rackGroup.rotation); + } + cageGroup.append(() => shape.node()); } }; @@ -865,7 +891,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit x: rackItem.xCoord - rack.x - group.x, // get cage coords by subtracting from both rack and group y: rackItem.yCoord - rack.y - group.y, size: svgSize, - mods: cageMods + mods: cageMods, }; newUnitLocs[cageNumType].push({ @@ -881,6 +907,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit const rackGroup: RackGroup = findOrAddGroup(rackItem); const rack: Rack = await findOrAddRack(rackGroup, rackItem); await addCageToRack(rack, rackItem, rackGroup); + }; // generates room object state for room objects from layout history data @@ -928,6 +955,55 @@ export const getAdjLocation = (loc: ModLocations): ModLocations => { } }; +export const cageDirectionToModLocation = (loc: CageDirection, rotation: GroupRotation): ModLocations => { + if(rotation === GroupRotation.Origin){ // 0 + switch (loc) { + case CageDirection.Left: + return ModLocations.Left; + case CageDirection.Right: + return ModLocations.Right; + case CageDirection.Top: + return ModLocations.Top; + case CageDirection.Bottom: + return ModLocations.Bottom; + } + }else if(rotation === GroupRotation.Quarter){ // 90 + switch (loc) { + case CageDirection.Left: + return ModLocations.Bottom; + case CageDirection.Right: + return ModLocations.Top; + case CageDirection.Top: + return ModLocations.Right; + case CageDirection.Bottom: + return ModLocations.Left; + } + }else if(rotation === GroupRotation.Half){ // 180 + switch (loc) { + case CageDirection.Left: + return ModLocations.Right; + case CageDirection.Right: + return ModLocations.Left; + case CageDirection.Top: + return ModLocations.Bottom; + case CageDirection.Bottom: + return ModLocations.Top; + } + }else if(rotation === GroupRotation.ThreeQuarter){ // 270 + switch (loc) { + case CageDirection.Left: + return ModLocations.Top; + case CageDirection.Right: + return ModLocations.Bottom; + case CageDirection.Top: + return ModLocations.Left; + case CageDirection.Bottom: + return ModLocations.Right; + } + } + +}; + export const getDefaultMod = (loc: ModLocations): ModTypes | null => { if (loc === ModLocations.Top || loc === ModLocations.Bottom) { return ModTypes.StandardFloor; @@ -1304,43 +1380,66 @@ export const saveRoomHelper = async (room: Room, sessionLog: SessionLog, oldTemp // Create default mods for new rooms. if (isRoomNonDefault) { - const usedMap = new Map(); room.rackGroups.forEach((group) => { group.racks.forEach((r) => { r.cages.forEach((c) => { - if (c.mods === undefined || c.mods === null) { - const connectedCages = findConnectedCages(r, group.rotation, c); - Object.entries(connectedCages).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; + const connectedCages = findConnectedCages(r, group.rotation, c); + const connectedRacks = findConnectedRacks(group, r, c); + + // Combine all potential connection directions from both adjacent cages and racks + const allDirections = new Set([ + ...Object.keys(connectedCages), + ...Object.keys(connectedRacks) + ]); + + allDirections.forEach((direction) => { + const locDir = parseInt(direction) as ModLocations; + const cageConnections = connectedCages[locDir] || []; + const rackConnections = connectedRacks[locDir] || []; + + // Only proceed if there is a connection in this direction + if (cageConnections.length > 0 || rackConnections.length > 0) { + if (c.mods && c.mods[locDir] && c.mods[locDir].length > 0) { + // If existing mods exist for this direction, add them + c.mods[locDir].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: locDir, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, + }); + }); + }); + } else { + // If no mods exist for this connection, add default ones + if (cageConnections.length > 0) { + addModEntries(cageConnections, locDir, r, false, newModData, usedMap); + } + if (rackConnections.length > 0) { + addModEntries(rackConnections, locDir, r, true, newModData, usedMap); + } } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, false, newModData, usedMap); - }); + } + }); - const connectedRacks = findConnectedRacks(group, r, c); - Object.entries(connectedRacks).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; - } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, true, newModData, usedMap); - }); - } else { - Object.entries(c.mods).forEach(([direction, modSubsections]: [string, CageModification[]]) => { - modSubsections.forEach(section => { - section.modKeys.forEach(key => { - newModData.push({ - cage: c.objectId, - location: parseInt(direction), - modId: key.modId, - modification: room.mods[key.modId].value, - parentModId: key.parentModId, - rack: r.objectId, - subId: section.subId, - }); + // Handle Direct location mods (not used in connections) + if (c.mods && c.mods[ModLocations.Direct] && c.mods[ModLocations.Direct].length > 0) { + c.mods[ModLocations.Direct].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: ModLocations.Direct, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, }); }); }); diff --git a/CageUI/src/org/labkey/cageui/CageUIManager.java b/CageUI/src/org/labkey/cageui/CageUIManager.java index ff7804ce4..c01e49eb8 100644 --- a/CageUI/src/org/labkey/cageui/CageUIManager.java +++ b/CageUI/src/org/labkey/cageui/CageUIManager.java @@ -965,17 +965,12 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund ArrayList templateForms = new ArrayList<>(); // Process rack groups - int rackGroupIndex = 0; for (RackGroup rackGroup : room.getRackGroups()) { - rackGroupIndex++; // Process racks in this group - int rackIndex = 0; for (Rack rack : rackGroup.getRacks()) { - rackIndex++; - // Process cages in this rack if (rack.getCages() != null) { @@ -983,9 +978,9 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund { TemplateLayoutHistoryForm form = new TemplateLayoutHistoryForm(); form.setHistoryId(historyId); - form.setRackGroup(rackGroupIndex); + form.setRackGroup(findLastNumberAfterDash(rackGroup.getGroupId())); form.setGroupRotation(rackGroup.getRotation()); - form.setRack(rackIndex); + form.setRack(rack.getItemId()); form.setCage(findLastNumberAfterDash(cage.getCageNum())); form.setObjectType(rack.getType().getEffectiveRackType().getNumericValue()); form.setExtraContext(cage.getExtraContext() != null ? diff --git a/CageUI/src/org/labkey/cageui/CageUIModule.java b/CageUI/src/org/labkey/cageui/CageUIModule.java index 6bde122a0..0ccbe402a 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 25.003; + return 26.001; } @Override diff --git a/CageUI/src/org/labkey/cageui/model/ModTypes.java b/CageUI/src/org/labkey/cageui/model/ModTypes.java index 6497ec2ef..6eb02f82a 100644 --- a/CageUI/src/org/labkey/cageui/model/ModTypes.java +++ b/CageUI/src/org/labkey/cageui/model/ModTypes.java @@ -31,6 +31,7 @@ public enum ModTypes PCDivider("pcd"), VCDivider("vcd"), PrivacyDivider("pd"), + LockedDivider("ld"), NoDivider("nd"), CTunnel("ct"), Extension("ex"),