From 35a382d5967e0dfb5d83cfe1e0d3d168ce0cac74 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Mon, 30 Mar 2026 10:56:53 -0500 Subject: [PATCH 01/24] Bugfix for template renaming --- .../components/layoutEditor/RoomSelectorPopup.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx index 9b91d9657..d879ca37b 100644 --- a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx +++ b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx @@ -72,15 +72,11 @@ export const RoomSelectorPopup: FC = (props) => { } if (templateName.length > 0) { - //return if new name doesn't have word template in it - if (!templateName.includes('template')) { - onCancel(); - return; - } + const newTemplateName = "template-" + templateName; // if template, save old template name for later setRoom(prevState => ({ ...prevState, - name: templateName + name: newTemplateName })); templateRename(selectedRoom); } else { @@ -109,7 +105,7 @@ export const RoomSelectorPopup: FC = (props) => { setTemplateName("template-" + e.target.value)} + onChange={(e) => setTemplateName(e.target.value)} /> } From 26f80638bc4f6e5504fac316af58abd1629befcc Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Mon, 30 Mar 2026 12:26:07 -0500 Subject: [PATCH 02/24] Bug fix for context menu disappearing from the page if it was positioned to close to the edge --- .../layoutEditor/EditorContextMenu.tsx | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx index 3c960c85c..31604f82d 100644 --- a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx +++ b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx @@ -64,7 +64,7 @@ export const EditorContextMenu: FC = (props) => { type } = props; - const menuRef = useRef(null); + const menuRef = useRef(null); // Delete object for room objects const handleDeleteObject = (e: React.MouseEvent) => { @@ -97,7 +97,44 @@ export const EditorContextMenu: FC = (props) => { return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [menuRef]); + }, [closeMenu]); + + // Handle dynamic positioning + useEffect(() => { + if (!menuRef.current || ctxMenuStyle.display !== 'block') return; + + const menu = menuRef.current; + const { top, left } = ctxMenuStyle; + const topValue = parseInt(top, 10); + const leftValue = parseInt(left, 10); + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const menuWidth = menu.offsetWidth; + const menuHeight = menu.offsetHeight; + + let adjustedTop = topValue; + let adjustedLeft = leftValue; + + // Prevent overflow to the right + if (leftValue + menuWidth > windowWidth) { + adjustedLeft = windowWidth - menuWidth - 10; + } + + // Prevent overflow to the bottom + if (topValue + menuHeight > windowHeight) { + adjustedTop = windowHeight - menuHeight - 10; + } + + // Prevent overflow to the left + if (adjustedLeft < 10) adjustedLeft = 10; + + // Prevent overflow to the top + if (adjustedTop < 10) adjustedTop = 10; + + menu.style.left = `${adjustedLeft}px`; + menu.style.top = `${adjustedTop}px`; + }, [ctxMenuStyle.display, ctxMenuStyle.left, ctxMenuStyle.top]); return (
Date: Tue, 31 Mar 2026 15:29:58 -0500 Subject: [PATCH 03/24] update watch config to get correct path for ios testing --- CageUI/ios-webpack/watch.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CageUI/ios-webpack/watch.config.js b/CageUI/ios-webpack/watch.config.js index 4d9097055..fada5a1c0 100644 --- a/CageUI/ios-webpack/watch.config.js +++ b/CageUI/ios-webpack/watch.config.js @@ -22,7 +22,7 @@ const constants = require('./constants'); const path = require('path'); // relative to the /node_modules/@labkey/build/webpack dir const entryPoints = require('../src/client/entryPoints.js'); -const host = require("host"); +const host = require("./host"); const devServer = { From 572a4e4b569e8bf55f130ba23f47b7cd870ff191 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Tue, 31 Mar 2026 15:30:35 -0500 Subject: [PATCH 04/24] bug fix for layout editor not adding mods to real room after loading in from template --- CageUI/src/client/utils/helpers.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 9936496c2..1ce43272a 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -758,13 +758,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit cageNumType = roomItemToString(defaultTypeToRackType(rackItem.objectType as DefaultRackTypes)); } - let cageMods: CageModificationsType = { - [ModLocations.Top]: [], - [ModLocations.Bottom]: [], - [ModLocations.Left]: [], - [ModLocations.Right]: [], - [ModLocations.Direct]: [] - }; + let cageMods: CageModificationsType; if (rackItem.extraContext) { extraContext = JSON.parse(rackItem.extraContext); } @@ -772,6 +766,13 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit // This is where mods are loaded into state for the room if (loadMods && !rack.type.isDefault) { + cageMods = { + [ModLocations.Top]: [], + [ModLocations.Bottom]: [], + [ModLocations.Left]: [], + [ModLocations.Right]: [], + [ModLocations.Direct]: [] + }; const modReturnData = await cageModLookup([], []); const availMods = modReturnData.map(row => ({value: row.value, label: row.title})); From e173faf8e94e538f331825d6ba2b88aa96bfdc80 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Tue, 31 Mar 2026 15:43:05 -0500 Subject: [PATCH 05/24] Update legend text spacing --- CageUI/resources/web/CageUI/static/legend.svg | 67 +++---------------- 1 file changed, 10 insertions(+), 57 deletions(-) diff --git a/CageUI/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index 9598caf23..049a54036 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -61,83 +61,39 @@ - S - olid Divider + Solid Divider - P - r - o - t - e - c - t - ed - C - o - n - ta - c - t Divider + Protected Contact Divider - V - isual - C - o - n - ta - c - t Divider + Visual Contact Divider - P - r - i - v - a - c - y Divider + Privacy Divider - S - tanda - r - d - F - loor + Standard Floor - M - esh - F - loor + Mesh Floor - M - esh - F - loor x2 + Mesh Floor x2 - E - x - t - ension + Extension - C - - - T - unnel + C-Tunnel @@ -151,9 +107,6 @@ - S - ocial - P - anel Divider + Social Panel Divider \ No newline at end of file From 7fa07ce5e61392861cf21296385ec5473f9225a0 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Thu, 2 Apr 2026 15:37:35 -0500 Subject: [PATCH 06/24] Add group svg wrapper to room objects allowing them to have context menus on mobile devices --- .../client/components/layoutEditor/Editor.tsx | 23 +-- .../src/client/utils/LayoutEditorHelpers.ts | 151 ++++++++++++++---- CageUI/src/client/utils/helpers.ts | 18 ++- 3 files changed, 139 insertions(+), 53 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index be4b01af1..7b930b6ac 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -190,11 +190,14 @@ const Editor: FC = ({roomSize}) => { if (!isRackEnum(updateItemType)) { // adding dragged room object group = layoutSvg.append('g') - .data([{x: cellX, y: cellY}]) .attr('class', 'draggable room-obj') - .attr('id', `${roomItemToString(updateItemType)}-${itemId}`) + .attr('id', `${roomItemToString(updateItemType)}-${itemId}-wrapper`) .style('pointer-events', 'bounding-box'); - group.append(() => draggedShape.node()); + + group.append('g') + .attr('id', `${roomItemToString(updateItemType)}-${itemId}`) + .attr('transform', `translate(0,0)`) + .append(() => draggedShape.node()); } else { // adding dragged caging unit const newRack: Rack = res as Rack; @@ -220,19 +223,7 @@ const Editor: FC = ({roomSize}) => { group.call(closeMenuThenDrag); - // attach click listener for context menu - if (isRackEnum(updateItemType)) { - group.selectAll('text').each(function () { - const textElement: SVGTextElement = d3.select(this).node() as SVGTextElement; - textElement.setAttribute('contentEditable', 'true'); - (textElement.children[0] as SVGTSpanElement).style.cursor = 'pointer'; - (textElement.children[0] as SVGTSpanElement).style.pointerEvents = 'auto'; - const cageGroupElement = textElement.closest(`[id="${((res as Rack).cages[0] as Cage).svgId}"]`) as SVGGElement; - setupEditCageEvent(cageGroupElement, setSelectedObj, contextMenuRef, 'edit', setCtxMenuStyle); - }); - } else { - setupEditCageEvent(group.node(), setSelectedObj, contextMenuRef, 'edit', setCtxMenuStyle); - } + setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef, setCtxMenuStyle); dragLockRef.current = false; }; diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 82bb7b32f..8953b5832 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -85,6 +85,10 @@ export const isCageModifier = (user: GetUserPermissionsResponse) => { return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIModificationEditorPermission'); }; +export const isTouchEvent = (event)=> { + return event.type.startsWith('touch'); +} + export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promise<{ fulfilled: FullObjectHistoryData[]; rejected: PromiseRejectedResult[] @@ -367,52 +371,129 @@ export function setupEditCageEvent( cageGroupElement: SVGGElement, setSelectedObj: React.Dispatch>, localRoomRef: MutableRefObject, - eventType: 'view' | 'edit', setCtxMenuStyle?: React.Dispatch>, ): () => void { - const handleContextMenu = (event: MouseEvent) => { - event.preventDefault(); - const localRoom = localRoomRef.current; + + // Main context menu handler + const handleContextMenu = (event: MouseEvent | CustomEvent) => { + // Only block native menu if we're using a custom one + if (setCtxMenuStyle && event.defaultPrevented === false) { + event.preventDefault(); + } + + const element = event.target as SVGGElement; let tempObj: SelectedObj; - const element = event.currentTarget as SVGGElement; - //set selected object to either room object or cage - if (d3.select(element).classed('room-obj')) { - tempObj = localRoom.objects.find((obj) => obj.itemId === element.id); + if (d3.select(element.parentElement).classed('room-obj')) { + tempObj = localRoomRef.current.objects.find(obj => obj.itemId === element.id); } else { const cageGroupElement = element.closest(`[id^="cageSVG_"]`) as SVGGElement | null; - const cageObj = localRoom.rackGroups.flatMap(g => g.racks).flatMap(r => r.cages).find(c => c.svgId === cageGroupElement.id); + const cageObj = localRoomRef.current.rackGroups + .flatMap(g => g.racks) + .flatMap(r => r.cages) + .find(c => c.svgId === cageGroupElement?.id); tempObj = cageObj; } + + if (!tempObj) return; // safety + setSelectedObj(tempObj); + if (setCtxMenuStyle) { - setCtxMenuStyle((prevState) => ({ - ...prevState, + const clientX = (event as MouseEvent).clientX; + const clientY = (event as MouseEvent).clientY; + + setCtxMenuStyle({ display: 'block', - left: `${event.clientX}px`, - top: `${event.clientY - 5}px`, - })); + left: `${clientX}px`, + top: `${clientY - 5}px`, + }); } + }; + // Touch gesture handlers + let touchStartTime = 0; + let touchStartX = 0; + let touchStartY = 0; + let touchTimer: number | null = null; + let isDragging = false; + + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) return; + + const touch = event.touches[0]; + touchStartTime = Date.now(); + touchStartX = touch.clientX; + touchStartY = touch.clientY; + isDragging = false; + + if (touchTimer) clearTimeout(touchTimer); + + // ⚠️ DO NOT preventDefault() here — let long-press begin! + touchTimer = window.setTimeout(() => { + if (!isDragging) { + event.preventDefault(); + // Create a trusted synthetic contextmenu event for iOS + const contextMenuEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: touch.clientX, + clientY: touch.clientY, + }) as MouseEvent; + + // Dispatch directly on the element + cageGroupElement.dispatchEvent(contextMenuEvent); + } + }, 500); // iOS default long-press is ~500ms }; - // Attach context menu to the lowest level group for that cage. - cageGroupElement.style.pointerEvents = 'bounding-box'; - if (eventType === 'edit') { - cageGroupElement.addEventListener('contextmenu', handleContextMenu); - } else { - cageGroupElement.addEventListener('click', handleContextMenu); - } + const handleTouchMove = (event: TouchEvent) => { + if (event.touches.length !== 1) return; - return () => { - if (eventType === 'edit') { - cageGroupElement.removeEventListener('contextmenu', handleContextMenu); - } else { - cageGroupElement.removeEventListener('click', handleContextMenu); + const touch = event.touches[0]; + const dx = Math.abs(touch.clientX - touchStartX); + const dy = Math.abs(touch.clientY - touchStartY); + + if (dx > 10 || dy > 10) { + isDragging = true; + if (touchTimer) { + clearTimeout(touchTimer); + touchTimer = null; + } + } + }; + + const handleTouchEnd = (event: TouchEvent) => { + if (touchTimer) { + clearTimeout(touchTimer); + touchTimer = null; } }; + + // Attach listeners + cageGroupElement.addEventListener('contextmenu', handleContextMenu); + cageGroupElement.addEventListener('touchstart', handleTouchStart); + cageGroupElement.addEventListener('touchmove', handleTouchMove); + cageGroupElement.addEventListener('touchend', handleTouchEnd); + + // Optional: Also support desktop right-click directly + cageGroupElement.addEventListener('mousedown', (e) => { + if (e.button === 2) { // right click + handleContextMenu(e); + } + }); + + return () => { + cageGroupElement.removeEventListener('contextmenu', handleContextMenu); + cageGroupElement.removeEventListener('touchstart', handleTouchStart); + cageGroupElement.removeEventListener('touchmove', handleTouchMove); + cageGroupElement.removeEventListener('touchend', handleTouchEnd); + // Also remove mousedown listener if added + cageGroupElement.removeEventListener('mousedown', (e) => { if (e.button === 2) handleContextMenu(e); }); + }; } + /* Helper function to either connect racks or merge cages @@ -449,7 +530,7 @@ export async function mergeRacks(props: MergeProps) { element.setAttribute('class', `grouped-${shapeType}`); element.setAttribute('style', ''); } - setupEditCageEvent(element, cageActionProps.setSelectedObj, contextMenuRef, 'edit', cageActionProps.setCtxMenuStyle); + setupEditCageEvent(element, cageActionProps.setSelectedObj, contextMenuRef, cageActionProps.setCtxMenuStyle); } // add starting x and y for each group to then increment its local subgroup coords by. @@ -776,7 +857,13 @@ export function createDragInLayout() { const element = d3.select(this); const transform = d3.zoomTransform(layoutSvg.node()); const scale = transform.k; - const [newX, newY] = d3.pointer(event.sourceEvent, this.parentNode); + let [newX, newY] = [0,0]; + if(isTouchEvent(event.sourceEvent)){ + [newX, newY] = d3.pointer(event.sourceEvent.touches[0], this.parentNode); + + }else{ + [newX, newY] = d3.pointer(event.sourceEvent, this.parentNode); + } element.attr('transform', `translate(${newX},${newY}) scale(${scale})`); } @@ -795,7 +882,13 @@ export function createEndDragInLayout(props: LayoutDragProps) { const layoutSvg: d3.Selection = d3.select('[id=layout-svg]'); const transform = d3.zoomTransform(layoutSvg.node()); - const [pointerX, pointerY] = d3.pointer(event, layoutSvg.node()); // mouse position with respect to layout svg + let [pointerX, pointerY] = [0,0]; + if(isTouchEvent(event.sourceEvent)){ + [pointerX, pointerY] = d3.pointer(event.sourceEvent.changedTouches[0], layoutSvg.node()); // mouse position with respect to layout svg + + }else{ + [pointerX, pointerY] = d3.pointer(event, layoutSvg.node()); // mouse position with respect to layout svg + } const {x, y} = getLayoutOffset({ clientX: pointerX, clientY: pointerY, diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 1ce43272a..9ba1687bb 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -526,9 +526,7 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac shape.classed('draggable', false); shape.style('pointer-events', 'none'); - const cageGroupContext = shape.select(`#${rackTypeString}`).node() as SVGGElement; // in order to set the event pass in the context menu ref and styles to show/hide it - setupEditCageEvent(cageGroupContext, setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum(cage.cageNum)}`; if (mode === 'view') { @@ -536,6 +534,7 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac } cageGroup.append(() => shape.node()); + setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); }); @@ -572,13 +571,16 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac }); (unitsToRender as Room).objects.forEach(async (roomObj) => { - const roomObjGroup = layoutSvg.append('g') - .data([{x: roomObj.x, y: roomObj.y}]) - .attr('id', roomObj.itemId) + const wrapperGroup = layoutSvg.append('g') + .attr('id', roomObj.itemId + '-wrapper') .attr('class', 'draggable room-obj') .attr('transform', `translate(${roomObj.x}, ${roomObj.y}) scale(${mode === 'edit' ? roomObj.scale : 1})`) .style('pointer-events', 'bounding-box'); + const roomObjGroup = wrapperGroup.append('g') + .attr('id', roomObj.itemId) + .attr('transform', `translate(0,0)`) + let objSvg: SVGElement; if (mode === 'edit') { @@ -596,9 +598,9 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac roomObjGroup.append(() => shape.node()); - placeAndScaleGroup(roomObjGroup, roomObj.x, roomObj.y, zoomTransform(layoutSvg.node())); - setupEditCageEvent(roomObjGroup.node() as SVGGElement, setSelectedObj, contextMenuRef, setCtxMenuStyle); - roomObjGroup.call(closeMenuThenDrag); + placeAndScaleGroup(wrapperGroup, roomObj.x, roomObj.y, zoomTransform(layoutSvg.node())); + setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + wrapperGroup.call(closeMenuThenDrag); }); } else if (renderType === 'group') { // we are rendering a single rack group createGroup(unitsToRender as RackGroup); From 6a434aa5d1ff10f98f22eea017dee836326383e7 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Thu, 2 Apr 2026 15:57:45 -0500 Subject: [PATCH 07/24] Bug fix for gate switching object after adding groups --- .../src/client/components/layoutEditor/Editor.tsx | 2 +- .../client/components/layoutEditor/GateSwitch.tsx | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index 7b930b6ac..f18cfdfd8 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -1110,9 +1110,9 @@ const Editor: FC = ({roomSize}) => { element: setShowObjectContextMenu(false)} />, types: [RoomObjectTypes.GateClosed, RoomObjectTypes.GateOpen], diff --git a/CageUI/src/client/components/layoutEditor/GateSwitch.tsx b/CageUI/src/client/components/layoutEditor/GateSwitch.tsx index aed214112..769b880ea 100644 --- a/CageUI/src/client/components/layoutEditor/GateSwitch.tsx +++ b/CageUI/src/client/components/layoutEditor/GateSwitch.tsx @@ -23,18 +23,17 @@ import { Room, RoomObject, RoomObjectTypes } from '../../types/typings'; import { parseRoomItemNum } from '../../utils/helpers'; interface GateSwitchProps { - layoutSvg: d3.Selection; selectedObj: RoomObject; setLocalRoom: React.Dispatch>; + setReloadRoom: React.Dispatch>; closeMenu: () => void; } export const GateSwitch: FC = (props) => { - const {layoutSvg, selectedObj, setLocalRoom, closeMenu} = props; + const {selectedObj, setLocalRoom, closeMenu, setReloadRoom} = props; // For each open or close, remove gate svg template of the opposite and replace with new version. Also switch id name version keeping id number const handleClick = () => { - const gateSvg = layoutSvg.select(`#${selectedObj.itemId}`); let newGateIdPrefix; if (selectedObj.type === RoomObjectTypes.GateOpen) { newGateIdPrefix = 'gateClosed'; @@ -42,13 +41,8 @@ export const GateSwitch: FC = (props) => { newGateIdPrefix = 'gateOpen'; } - const newGateSvg = (d3.select(`#${newGateIdPrefix}_template_wrapper`) as d3.Selection).node().cloneNode(true) as SVGElement; - gateSvg.selectChild().remove(); - gateSvg.append(() => newGateSvg); - gateSvg.attr('id', `${newGateIdPrefix}-${parseRoomItemNum((selectedObj as RoomObject).itemId)}`); - setLocalRoom(prevState => { - return { + const newRoom = { ...prevState, objects: prevState.objects.map((obj) => { if (obj.itemId === selectedObj.itemId) { @@ -61,7 +55,10 @@ export const GateSwitch: FC = (props) => { return obj; }) }; + setReloadRoom(newRoom); + return newRoom; }); + closeMenu(); }; return ( From 1a03470e5d31e21e6f7ad05d86e8b6b601881057 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Fri, 3 Apr 2026 12:12:25 -0500 Subject: [PATCH 08/24] Fix deletion and movement for room objects --- CageUI/src/client/components/layoutEditor/Editor.tsx | 5 +++-- CageUI/src/client/context/LayoutEditorContextManager.tsx | 4 ++-- CageUI/src/client/utils/LayoutEditorHelpers.ts | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index f18cfdfd8..a637af477 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -783,14 +783,15 @@ const Editor: FC = ({roomSize}) => { const handleDelObject = () => { - const selectionToDel = layoutSvg.select(`#${(selectedObj as RoomObject).itemId}`); + const objId = (selectedObj as RoomObject).itemId; + const selectionToDel = layoutSvg.select(`#${objId}-wrapper`); let selectionName = selectionToDel.select('.injected-svg').attr('id'); // name from id in file/injected svg // parses the first word if id contains multiple words. selectionName = selectionName.indexOf('_') !== -1 ? selectionName.slice(0, selectionName.indexOf('_')) : selectionName; showLayoutEditorConfirmation(`Are you sure you want to delete ${selectionName}`).then((r) => { if (r) { selectionToDel.remove(); - delObject(selectionToDel.attr('id')); + delObject(objId); } }); diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index 33f629e1c..1a6535744 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -43,7 +43,7 @@ import { } from '../types/typings'; import { CellKey, DeleteActions, LayoutSaveResult, RackActions, SelectedObj } from '../types/layoutEditorTypes'; import { - createEmptyUnitLoc, + createEmptyUnitLoc, extractRoomObjId, findCageInGroup, findRackInGroup, findSelectObjRack, @@ -676,7 +676,7 @@ export const LayoutEditorContextProvider: FC = ({children, p updatedLocalRoom = { ...prevRoom, objects: prevRoom.objects.map(item => - item.itemId === itemId + item.itemId === extractRoomObjId(itemId) ? {...item, x, y, scale: k} : item ) diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 8953b5832..52c6e6952 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -89,6 +89,11 @@ export const isTouchEvent = (event)=> { return event.type.startsWith('touch'); } +// removes the wrapper for the id portion of room objects to properly move the object. +export const extractRoomObjId = (id: string) => { + return id.replace(/-wrapper$/, ''); +} + export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promise<{ fulfilled: FullObjectHistoryData[]; rejected: PromiseRejectedResult[] From 447606a2e582ab1dcfe5e108ab23b60efc444433 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Fri, 3 Apr 2026 16:44:35 -0500 Subject: [PATCH 09/24] Update permissions for tables --- .../org/labkey/cageui/query/AllHistoryTable.java | 10 ++++++---- .../org/labkey/cageui/query/CageHistoryTable.java | 12 ++++++++---- .../query/CageModificationsHistoryTable.java | 12 ++++++++---- .../cageui/query/CageModificationsTable.java | 6 +++--- CageUI/src/org/labkey/cageui/query/CagesTable.java | 14 +++++++++----- .../labkey/cageui/query/LayoutHistoryTable.java | 14 ++++++++------ .../org/labkey/cageui/query/RackTypesTable.java | 13 ++++++++----- CageUI/src/org/labkey/cageui/query/RacksTable.java | 14 +++++++++----- .../org/labkey/cageui/query/RoomHistoryTable.java | 12 ++++++++---- .../cageui/query/TemplateLayoutHistoryTable.java | 6 +++--- .../security/roles/CageUIRoomModifierRole.java | 1 + 11 files changed, 71 insertions(+), 43 deletions(-) diff --git a/CageUI/src/org/labkey/cageui/query/AllHistoryTable.java b/CageUI/src/org/labkey/cageui/query/AllHistoryTable.java index b8c9007a0..47ba2c50d 100644 --- a/CageUI/src/org/labkey/cageui/query/AllHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/AllHistoryTable.java @@ -72,7 +72,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> getRows(User user, Container container, List> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (super.hasPermission(user, CageUILayoutEditorAccessPermission.class) || super.hasPermission(user, CageUIModificationEditorPermission.class) || super.hasPermission(user, CageUIAnimalEditorPermission.class)) + if (super.hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -102,7 +104,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (super.hasPermission(user, CageUILayoutEditorAccessPermission.class) || super.hasPermission(user, CageUIModificationEditorPermission.class) || super.hasPermission(user, CageUIAnimalEditorPermission.class)) + if (super.hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -114,7 +116,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (super.hasPermission(user, CageUILayoutEditorAccessPermission.class) || super.hasPermission(user, CageUIModificationEditorPermission.class) || super.hasPermission(user, CageUIAnimalEditorPermission.class)) + if (super.hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/CageHistoryTable.java b/CageUI/src/org/labkey/cageui/query/CageHistoryTable.java index dece13225..fdb883628 100644 --- a/CageUI/src/org/labkey/cageui/query/CageHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/CageHistoryTable.java @@ -35,6 +35,8 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; +import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import java.sql.SQLException; @@ -69,7 +71,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -93,7 +97,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -105,7 +109,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/CageModificationsHistoryTable.java b/CageUI/src/org/labkey/cageui/query/CageModificationsHistoryTable.java index e2efd23b7..aea44c043 100644 --- a/CageUI/src/org/labkey/cageui/query/CageModificationsHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/CageModificationsHistoryTable.java @@ -35,6 +35,8 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; +import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import java.sql.SQLException; @@ -69,7 +71,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -93,7 +97,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -105,7 +109,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUIModificationEditorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/CageModificationsTable.java b/CageUI/src/org/labkey/cageui/query/CageModificationsTable.java index 634cfca0a..138559701 100644 --- a/CageUI/src/org/labkey/cageui/query/CageModificationsTable.java +++ b/CageUI/src/org/labkey/cageui/query/CageModificationsTable.java @@ -81,7 +81,7 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -95,7 +95,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -107,7 +107,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/CagesTable.java b/CageUI/src/org/labkey/cageui/query/CagesTable.java index f299a23a8..728677675 100644 --- a/CageUI/src/org/labkey/cageui/query/CagesTable.java +++ b/CageUI/src/org/labkey/cageui/query/CagesTable.java @@ -36,6 +36,9 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; +import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; +import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission; import java.sql.SQLException; @@ -68,10 +71,11 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -95,7 +99,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -107,7 +111,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/LayoutHistoryTable.java b/CageUI/src/org/labkey/cageui/query/LayoutHistoryTable.java index 360923f85..445473551 100644 --- a/CageUI/src/org/labkey/cageui/query/LayoutHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/LayoutHistoryTable.java @@ -36,7 +36,9 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; +import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission; import org.labkey.cageui.security.permissions.CageUITemplateCreatorPermission; @@ -72,9 +74,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class) || hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -98,7 +100,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUILayoutEditorAccessPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -110,7 +112,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/RackTypesTable.java b/CageUI/src/org/labkey/cageui/query/RackTypesTable.java index 73e9910d6..b6c67bd35 100644 --- a/CageUI/src/org/labkey/cageui/query/RackTypesTable.java +++ b/CageUI/src/org/labkey/cageui/query/RackTypesTable.java @@ -36,6 +36,10 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; +import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; +import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; +import org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission; import org.labkey.cageui.security.permissions.CageUITemplateCreatorPermission; import java.sql.SQLException; @@ -68,10 +72,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -95,7 +98,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -107,7 +110,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/RacksTable.java b/CageUI/src/org/labkey/cageui/query/RacksTable.java index 85c722ba4..a5eca6ae2 100644 --- a/CageUI/src/org/labkey/cageui/query/RacksTable.java +++ b/CageUI/src/org/labkey/cageui/query/RacksTable.java @@ -36,6 +36,9 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; +import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; +import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import org.labkey.cageui.security.permissions.CageUITemplateCreatorPermission; import java.sql.SQLException; @@ -68,10 +71,11 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -96,7 +100,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -108,7 +112,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/RoomHistoryTable.java b/CageUI/src/org/labkey/cageui/query/RoomHistoryTable.java index c58c7a35d..77d4ae216 100644 --- a/CageUI/src/org/labkey/cageui/query/RoomHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/RoomHistoryTable.java @@ -36,7 +36,9 @@ import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.cageui.security.permissions.CageUIAnimalEditorPermission; import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; +import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import java.sql.SQLException; import java.util.List; @@ -70,7 +72,9 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUILayoutEditorAccessPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -94,7 +98,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUILayoutEditorAccessPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -106,7 +110,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUILayoutEditorAccessPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/query/TemplateLayoutHistoryTable.java b/CageUI/src/org/labkey/cageui/query/TemplateLayoutHistoryTable.java index dab606481..60bbcdaaa 100644 --- a/CageUI/src/org/labkey/cageui/query/TemplateLayoutHistoryTable.java +++ b/CageUI/src/org/labkey/cageui/query/TemplateLayoutHistoryTable.java @@ -82,7 +82,7 @@ public boolean hasPermission(@NotNull UserPrincipal user, Class> insertRows(User user, Container container, List> rows, BatchValidationException errors, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws DuplicateKeyException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUITemplateCreatorPermission.class) || hasPermission(user, CageUIRoomCreatorPermission.class)) + if (hasPermission(user, InsertPermission.class)) { result = super._insertRowsUsingDIB(user, container, rows, getDataIteratorContext(errors, InsertOption.INSERT, configParameters), extraScriptContext); } @@ -96,7 +96,7 @@ public List> updateRows(User user, Container container, List throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { List> result = null; - if (hasPermission(user, CageUILayoutEditorAccessPermission.class)) + if (hasPermission(user, UpdatePermission.class)) { result = super.updateRows(user, container, rows, oldKeys, errors, configParameters, extraScriptContext); } @@ -108,7 +108,7 @@ public List> updateRows(User user, Container container, List public List> deleteRows(User user, Container container, List> keys, @Nullable Map configParameters, @Nullable Map extraScriptContext) throws SQLException, BatchValidationException, QueryUpdateServiceException, InvalidKeyException { - if (hasPermission(user, CageUITemplateCreatorPermission.class)) + if (hasPermission(user, DeletePermission.class)) { return super.deleteRows(user, container, keys, configParameters, extraScriptContext); } diff --git a/CageUI/src/org/labkey/cageui/security/roles/CageUIRoomModifierRole.java b/CageUI/src/org/labkey/cageui/security/roles/CageUIRoomModifierRole.java index 9afbf6686..86d79af83 100644 --- a/CageUI/src/org/labkey/cageui/security/roles/CageUIRoomModifierRole.java +++ b/CageUI/src/org/labkey/cageui/security/roles/CageUIRoomModifierRole.java @@ -34,6 +34,7 @@ public CageUIRoomModifierRole() this("Cage UI Room Modifier Role", "Room modifier role for Cage UI", CageUIRoomModifierPermission.class, + CageUILayoutEditorAccessPermission.class, CageUIAnimalEditorPermission.class, CageUIModificationEditorPermission.class, CageUINotesEditorPermission.class From 05dc6e60996b1a17b21fab5a76c5a3ae137e4303 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Fri, 3 Apr 2026 16:44:50 -0500 Subject: [PATCH 10/24] Add permissions to drag objects across layout --- .../home/cageView/CurrentCageLayout.tsx | 4 +-- .../components/home/roomView/RoomLayout.tsx | 4 +-- .../client/components/layoutEditor/Editor.tsx | 11 ++++--- .../pages/layoutEditor/LayoutEditor.tsx | 32 ++++++++++++------- .../src/client/utils/LayoutEditorHelpers.ts | 15 ++++++++- CageUI/src/client/utils/helpers.ts | 13 +++++--- 6 files changed, 54 insertions(+), 25 deletions(-) diff --git a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx index 01467b5be..f05dfb9a5 100644 --- a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx +++ b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx @@ -31,7 +31,7 @@ interface CurrentCageLayoutProps { export const CurrentCageLayout: FC = (props) => { const {cage} = props; - const {selectedRoom} = useHomeNavigationContext(); + const {selectedRoom, userProfile} = useHomeNavigationContext(); const cageRef = useRef(null); @@ -45,7 +45,7 @@ export const CurrentCageLayout: FC = (props) => { const element = d3.select(this) as d3.Selection; element.remove(); }); - addPrevRoomSvgs('view', cage, cageSvg, selectedRoom, selectedRoom.mods); + addPrevRoomSvgs(userProfile, 'view', cage, cageSvg, selectedRoom, selectedRoom.mods); }, [cage]); // adding 1 to the width/height helps make sure the lines don't get cut off in the image diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index e7177fc47..3eda117cb 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -39,7 +39,7 @@ interface RoomLayoutProps { export const RoomLayout: FC = (props) => { const {submitLayoutMods} = useRoomContext(); - const {selectedRoom, selectedRoomMods, navigateTo} = useHomeNavigationContext(); + const {selectedRoom, selectedRoomMods, navigateTo, userProfile} = useHomeNavigationContext(); const [selectedContextObj, setSelectedContextObj] = useState(null); const [showCageContextMenu, setShowCageContextMenu] = useState(false); const [showChangesMenu, setShowChangesMenu] = useState(false); @@ -60,7 +60,7 @@ export const RoomLayout: FC = (props) => { d3.select('#layout-svg').selectAll('*:not(#layout-border, #layout-border *)').remove(); const layoutSvg = d3.select('#layout-svg') as d3.Selection; contextRef.current = selectedRoom; - addPrevRoomSvgs('view', selectedRoom, layoutSvg,undefined, selectedRoom.mods, setSelectedContextObj, contextRef); + addPrevRoomSvgs(userProfile,'view', selectedRoom, layoutSvg,undefined, selectedRoom.mods, setSelectedContextObj, contextRef); }, [selectedRoom.name, showCageContextMenu]); diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index a637af477..d482caef8 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -57,7 +57,7 @@ import { findCageInGroup, findRackInGroup, getLayoutOffset, - getTargetRect, + getTargetRect, isDraggable, isRackEnum, isRoomCreator, isTemplateCreator, @@ -190,7 +190,7 @@ const Editor: FC = ({roomSize}) => { if (!isRackEnum(updateItemType)) { // adding dragged room object group = layoutSvg.append('g') - .attr('class', 'draggable room-obj') + .attr('class', `draggable room-obj type-${roomItemToString(updateItemType)}`) .attr('id', `${roomItemToString(updateItemType)}-${itemId}-wrapper`) .style('pointer-events', 'bounding-box'); @@ -221,7 +221,10 @@ const Editor: FC = ({roomSize}) => { placeAndScaleGroup(group, cellX, cellY, transform); - group.call(closeMenuThenDrag); + if(isDraggable(user, updateItemType)){ + group.call(closeMenuThenDrag); + + } setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef, setCtxMenuStyle); @@ -677,7 +680,7 @@ const Editor: FC = ({roomSize}) => { } }); // loads grid with new room - addPrevRoomSvgs('edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag); + addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag); setReloadRoom(null); }, [reloadRoom]); diff --git a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx index be97c5556..098693503 100644 --- a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx +++ b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx @@ -27,13 +27,13 @@ import Editor from '../../components/layoutEditor/Editor'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { RoomSizeSelector, SelectorOptions } from '../../components/layoutEditor/RoomSizeSelector'; import { ConfirmationPopup } from '../../components/ConfirmationPopup'; -import { isRoomCreator, isTemplateCreator } from '../../utils/LayoutEditorHelpers'; +import { isRoomCreator, isRoomModifier, isTemplateCreator } from '../../utils/LayoutEditorHelpers'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; import { roomSizeOptions } from '../../utils/constants'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; export const LayoutEditor: FC = () => { - const roomName = ActionURL.getParameter('room'); + const roomName: string = ActionURL.getParameter('room'); const [prevRoomData, setPrevRoomData] = useState({ name: null, cagingData: [], @@ -62,7 +62,10 @@ export const LayoutEditor: FC = () => { // if the user is a template creator grant access if (isTemplateCreator(profile) || (isRoomCreator(profile))) { setAccess(true); - }else{ + }else if(isRoomModifier(profile) && roomName && !roomName.includes("template")){ // ensure room modifiers are editing a real room + setAccess(true); + } + else{ setAccess(false); } } @@ -145,7 +148,10 @@ export const LayoutEditor: FC = () => { } }, [prevRoomData]); - return (!isLoading && userProfile && access) ? ( + return (isLoading) ? +
+

Page is loading, please wait.

+
: (!isLoading && userProfile && access) ? ( = () => {
} /> - ) :
-

Error loading page. This could be due to a number of issues

-
    -
  • Insufficient permissions
  • -
  • Slow load times
  • -
  • New bugs on our end. If you believe this might be the issue please submit a ticket.
  • -
-
; + ) : (!isLoading && !access) ? + ( +
+

Error loading page. You do not have sufficient permissions. Please open a ticket if you believe this is a mistake.

+
+ ) : ( +
+

Error loading page. Please submit a ticket.

+
+ ); }; \ No newline at end of file diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 52c6e6952..399ebb97a 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -33,7 +33,6 @@ import { CageDirection, CageHistoryData, CageMods, - CageNumber, CageSvgId, DefaultRackTypes, FullObjectHistoryData, @@ -50,6 +49,7 @@ import { RoomItemClass, RoomItemStringType, RoomItemType, + RoomObjectTypes, UnitLocations } from '../types/typings'; import { @@ -94,6 +94,19 @@ export const extractRoomObjId = (id: string) => { return id.replace(/-wrapper$/, ''); } +// determines if the user has access to manipulating room layout objects +export const isDraggable = (user, itemType: RoomItemType) => { + if(isRoomCreator(user) || isTemplateCreator(user)) { + return true; + } + if(isRoomModifier(user)){ + if (RoomObjectTypes.RoomDivider === itemType){ + return true; + } + } + return false; +} + export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promise<{ fulfilled: FullObjectHistoryData[]; rejected: PromiseRejectedResult[] diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 9ba1687bb..d6835428a 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -63,7 +63,7 @@ import { addModEntries, areAllRacksNonDefault, createEmptyUnitLoc, - findCageInGroup, + findCageInGroup, isDraggable, isRackEnum, isRoomHomogeneousDefault, placeAndScaleGroup, @@ -78,6 +78,7 @@ import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; import { labkeyActionSelectWithPromise, saveRoomLayout } from '../api/labkeyActions'; import { cageModLookup } from '../api/popularQueries'; import { ConnectedCages, ConnectedRacks } from '../types/homeTypes'; +import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; export const generateCageId = (objectId: string): CageSvgId => { @@ -447,7 +448,7 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) // 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 = (mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { +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?) => { let renderType: 'room' | 'group' | 'rack' | 'cage'; if ((unitsToRender as Room)?.rackGroups) { @@ -560,7 +561,9 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac let groupY = renderType === 'room' ? group.y : group.racks[0].y; placeAndScaleGroup(parentGroup, groupX, groupY, zoomTransform(layoutSvg.node())); if (mode === 'edit') { - parentGroup.call(closeMenuThenDrag); + if(isDraggable(user, group.racks[0].type.type)){ + parentGroup.call(closeMenuThenDrag); + } } }; @@ -600,7 +603,9 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac roomObjGroup.append(() => shape.node()); placeAndScaleGroup(wrapperGroup, roomObj.x, roomObj.y, zoomTransform(layoutSvg.node())); setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); - wrapperGroup.call(closeMenuThenDrag); + if(isDraggable(user, roomObj.type)){ + wrapperGroup.call(closeMenuThenDrag); + } }); } else if (renderType === 'group') { // we are rendering a single rack group createGroup(unitsToRender as RackGroup); From 014be8e497c8112cc64a6a28d5a9240d88980947 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Mon, 6 Apr 2026 13:54:44 -0500 Subject: [PATCH 11/24] Add permissions for adding to layout, updating border size, clearing layout, and opening context menus --- .../client/components/layoutEditor/Editor.tsx | 50 ++++++++++++++----- .../src/client/utils/LayoutEditorHelpers.ts | 31 +++++++++++- CageUI/src/client/utils/helpers.ts | 13 +++-- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index d482caef8..6d1c3844c 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -46,7 +46,7 @@ import { } from '../../types/layoutEditorTypes'; import { LayoutTooltip } from './LayoutTooltip'; import { - areCagesInSameRack, + areCagesInSameRack, canOpenContextMenu, canPlaceObject, checkAdjacent, createDragInLayout, createEmptyUnitLoc, @@ -117,6 +117,7 @@ const Editor: FC = ({roomSize}) => { const [templateOptions, setTemplateOptions] = useState(false); const [templateRename, setTemplateRename] = useState(null); const [startSaving, setStartSaving] = useState(false); + const [showPermissionError, setShowPermissionError] = useState(false); // number of cells in grid width/height, based off scale const gridWidth = Math.ceil(SVG_WIDTH / roomSize.scale / CELL_SIZE); @@ -220,13 +221,15 @@ const Editor: FC = ({roomSize}) => { } placeAndScaleGroup(group, cellX, cellY, transform); - + // attach drag if user has permissions if(isDraggable(user, updateItemType)){ group.call(closeMenuThenDrag); } - - setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef, setCtxMenuStyle); + // attach context menu if user has permissions + if(canOpenContextMenu(user, updateItemType)){ + setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef, setCtxMenuStyle); + } dragLockRef.current = false; }; @@ -262,6 +265,16 @@ const Editor: FC = ({roomSize}) => { shape = event.sourceEvent.target.cloneNode(true) as SVGElement; } + const draggedNodeId = d3.select(shape).attr('id'); + + const updateItemType: RoomItemType = stringToRoomItem(parseWrapperId(draggedNodeId)); + + if(!canPlaceObject(user, updateItemType)){ + // set error msg denying access to place this object + setShowPermissionError(true); + return; + } + d3.select(shape) .style('pointer-events', 'none') .attr('class', 'dragging'); @@ -641,7 +654,8 @@ const Editor: FC = ({roomSize}) => { } // Attach x and y data to border group and drag call for resizing placeAndScaleGroup(borderGroup, 0, 0, zoomTransform(layoutSvg.node())); - borderGroup.call( + if(isTemplateCreator(user) || isRoomCreator(user)){ + borderGroup.call( dragBorder( () => { setShowObjectContextMenu(false); @@ -650,8 +664,10 @@ const Editor: FC = ({roomSize}) => { CELL_SIZE, borderGroup, setLocalRoom - ) - ); + ) + ); + } + // Set zoom after border is loaded in zoomToScale(roomSize.scale); @@ -969,12 +985,14 @@ const Editor: FC = ({roomSize}) => { data-tg-on="Grid Enabled" htmlFor="cb3-8"> - + {(isRoomCreator(user) || isTemplateCreator(user)) && + + } {isTemplateCreator(user) && + {showPermissionError && + setShowPermissionError(null)} + /> + } {showSaveConfirm && ${localRoom.name} ?`} diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 399ebb97a..4cbc3f7e1 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -94,8 +94,8 @@ export const extractRoomObjId = (id: string) => { return id.replace(/-wrapper$/, ''); } -// determines if the user has access to manipulating room layout objects -export const isDraggable = (user, itemType: RoomItemType) => { +// Determines if the user has access to dragging the item +export const isDraggable = (user: GetUserPermissionsResponse, itemType: RoomItemType) => { if(isRoomCreator(user) || isTemplateCreator(user)) { return true; } @@ -107,6 +107,33 @@ export const isDraggable = (user, itemType: RoomItemType) => { return false; } +// Determines if the user can open the items context menu +export const canOpenContextMenu = (user: GetUserPermissionsResponse, itemType: RoomItemType) => { + if(isRoomCreator(user) || isTemplateCreator(user)) { + return true; + } + if(isRoomModifier(user)){ + if (RoomObjectTypes.RoomDivider === itemType){ + return true; + } + } + return false; +} + +export const canPlaceObject = (user: GetUserPermissionsResponse, itemType: RoomItemType) => { + if(isRoomCreator(user) || isTemplateCreator(user)) { + return true; + } + if(isRoomModifier(user)){ + if (RoomObjectTypes.RoomDivider === itemType){ + return true; + } + } + return false; +} + + + export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promise<{ fulfilled: FullObjectHistoryData[]; rejected: PromiseRejectedResult[] diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index d6835428a..033fb19d6 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -61,7 +61,7 @@ import { MutableRefObject } from 'react'; import { ActionURL, Filter, Utils } from '@labkey/api'; import { addModEntries, - areAllRacksNonDefault, + areAllRacksNonDefault, canOpenContextMenu, createEmptyUnitLoc, findCageInGroup, isDraggable, isRackEnum, @@ -535,7 +535,10 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | } cageGroup.append(() => shape.node()); - setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + // attach context menu if user has permissions for cages + if(canOpenContextMenu(user, rack.type.type)){ + setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + } }); @@ -602,7 +605,11 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | roomObjGroup.append(() => shape.node()); placeAndScaleGroup(wrapperGroup, roomObj.x, roomObj.y, zoomTransform(layoutSvg.node())); - setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + // Attach context menu if user has permissions for room objects + if(canOpenContextMenu(user, roomObj.type)){ + setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + } + // Attach drag functionality if user has permissions if(isDraggable(user, roomObj.type)){ wrapperGroup.call(closeMenuThenDrag); } From 6e02bd5b19016e5f10455be0a19a83026456bb97 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Wed, 8 Apr 2026 16:44:46 -0500 Subject: [PATCH 12/24] Add session log for forms --- CageUI/src/client/api/labkeyActions.ts | 6 +- .../context/LayoutEditorContextManager.tsx | 24 +++- .../src/client/context/RoomContextManager.tsx | 18 ++- CageUI/src/client/types/typings.ts | 22 +++ .../org/labkey/cageui/CageUIController.java | 43 +++++- .../src/org/labkey/cageui/CageUIManager.java | 56 ++++++++ .../org/labkey/cageui/model/SessionLog.java | 127 ++++++++++++++++++ 7 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 CageUI/src/org/labkey/cageui/model/SessionLog.java diff --git a/CageUI/src/client/api/labkeyActions.ts b/CageUI/src/client/api/labkeyActions.ts index efc8e7022..332fd793f 100644 --- a/CageUI/src/client/api/labkeyActions.ts +++ b/CageUI/src/client/api/labkeyActions.ts @@ -21,7 +21,7 @@ import { ActionURL, Ajax, Query, Security, Utils } from '@labkey/api'; import { Command, QueryRequestOptions, SaveRowsOptions, SaveRowsResponse } from '@labkey/api/dist/labkey/query/Rows'; import { GetUserPermissionsOptions } from '@labkey/api/dist/labkey/security/Permission'; import { SelectDistinctOptions } from '@labkey/api/dist/labkey/query/SelectDistinctRows'; -import { CageMods, Rack, RackConditionOption, Room } from '../types/typings'; +import { CageMods, Rack, RackConditionOption, Room, SessionLog } from '../types/typings'; import { buildURL } from '@labkey/components'; import { RackSwitchOption } from '../types/homeTypes'; @@ -142,7 +142,7 @@ export const labkeyGetUserPermissions = (config?: GetUserPermissionsOptions) => }); }; -export function saveRoomLayout(room: Room, mods: CageMods[], prevRoomName: string, prevRackCondition?: RackConditionOption): Promise<{ +export function saveRoomLayout(room: Room, mods: CageMods[], prevRoomName: string, sessionLog: SessionLog, prevRackCondition?: RackConditionOption): Promise<{ success: boolean, errors: any[] }> { @@ -164,7 +164,7 @@ export function saveRoomLayout(room: Room, mods: CageMods[], prevRoomName: strin method: 'POST', success: (res) => resolve(JSON.parse(res.response)), failure: Utils.getCallbackWrapper((error) => reject(error)), - jsonData: {mods: mods, room: room, prevRoomName: newPrevRoomName, isDefault: isDefault, prevRackCondition: prevRackCondition}, + jsonData: {mods: mods, room: room, prevRoomName: newPrevRoomName, isDefault: isDefault, prevRackCondition: prevRackCondition, sessionLog: sessionLog}, }); }); } diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index 1a6535744..d8fbfcad4 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -39,9 +39,16 @@ import { RoomObject, RoomObjectTypes, UnitLocations, - UnitType + UnitType, + SessionLog } from '../types/typings'; -import { CellKey, DeleteActions, LayoutSaveResult, RackActions, SelectedObj } from '../types/layoutEditorTypes'; +import { + CellKey, + DeleteActions, + LayoutSaveResult, + RackActions, + SelectedObj, +} from '../types/layoutEditorTypes'; import { createEmptyUnitLoc, extractRoomObjId, findCageInGroup, @@ -63,7 +70,7 @@ import { parseRoomItemType, rackTypeToDefaultType, roomItemToString, - saveRoomHelper + saveRoomHelper, toLabKeyDate } from '../utils/helpers'; import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; import { Filter } from '@labkey/api'; @@ -85,6 +92,12 @@ export const useLayoutEditorContext = () => { }; export const LayoutEditorContextProvider: FC = ({children, prevRoom, user}) => { + const [sessionLog, setSessionLog] = useState({ + startTime: toLabKeyDate(new Date()), + userAgent: navigator.userAgent, + schemaName: 'cageui', + queryName: 'layout_history' + }); // loaded in and unchanged since start of layout editing const [room, setRoom] = useState({ name: 'new-layout', @@ -138,6 +151,9 @@ export const LayoutEditorContextProvider: FC = ({children, p // instead of tying scale to each location, manage one scale for the whole layout const [scale, setScale] = useState(1); + useEffect(() => { + console.log("Log: ", sessionLog); + }, [sessionLog]); const grid = useRef>(new Map()); @@ -1153,7 +1169,7 @@ export const LayoutEditorContextProvider: FC = ({children, p }; const saveRoom = async (oldTemplateName?: string): Promise => { - return saveRoomHelper(localRoom, oldTemplateName); + return saveRoomHelper(localRoom, sessionLog, oldTemplateName ); }; useEffect(() => { diff --git a/CageUI/src/client/context/RoomContextManager.tsx b/CageUI/src/client/context/RoomContextManager.tsx index a4fc95346..c0f23f3b6 100644 --- a/CageUI/src/client/context/RoomContextManager.tsx +++ b/CageUI/src/client/context/RoomContextManager.tsx @@ -17,10 +17,10 @@ */ import * as React from 'react'; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useState } from 'react'; import { RoomContextType } from '../types/roomContextTypes'; -import { getAdjLocation, saveRoomHelper } from '../utils/helpers'; +import { getAdjLocation, saveRoomHelper, toLabKeyDate } from '../utils/helpers'; import { Cage, CageModification, @@ -30,7 +30,7 @@ import { Rack, RackConditionOption, Room, - RoomMods + RoomMods, SessionLog } from '../types/typings'; import { ModificationSaveResult, RackSwitchOption } from '../types/homeTypes'; import { LayoutSaveResult, RackChangeSaveResult } from '../types/layoutEditorTypes'; @@ -54,6 +54,12 @@ export const useRoomContext = () => { export const RoomContextProvider = ({children}) => { const {selectedRoom, setSelectedRoom} = useHomeNavigationContext(); + const [sessionLog, setSessionLog] = useState({ + startTime: toLabKeyDate(new Date()), + userAgent: navigator.userAgent, + schemaName: 'cageui', + queryName: null, + }); const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { const cageModsByCage: { [key in string]: CageModificationsType } = {}; // string is object uuid @@ -177,7 +183,8 @@ export const RoomContextProvider = ({children}) => { }; const submitLayoutMods = async (): Promise => { - return saveRoomHelper(selectedRoom); + const newSessionLog: SessionLog = {...sessionLog, queryName: 'cage_modifications_history'}; + return saveRoomHelper(selectedRoom, newSessionLog); }; const submitRackChange = async (newRackOption: RackSwitchOption, prevRack: Rack, prevRackCondition: RackConditionOption): Promise => { @@ -185,6 +192,7 @@ export const RoomContextProvider = ({children}) => { let result: RackChangeSaveResult; let newRoom: Room; let newRack: string; + const newSessionLog: SessionLog = {...sessionLog, queryName: 'rack_history'}; try { const newRoomRes = await createNewRoomFromRackChange(selectedRoom, newRackOption, prevRack); newRoom = newRoomRes.room; @@ -206,7 +214,7 @@ export const RoomContextProvider = ({children}) => { }; return result; } - const saveRoomRes = await saveRoomHelper(newRoom,null, prevRackCondition); + const saveRoomRes = await saveRoomHelper(newRoom,newSessionLog, null, prevRackCondition); return { ...saveRoomRes, rack: newRack, diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index de5bd92df..26ef50f9d 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -426,4 +426,26 @@ export interface RackChangeOption { export interface RackConditionOption { value: RackConditions; label: string; +} + +/* + In order to fit the wnprc.session_log format and work around the fact that the cageui submits data to many different + tables for each room update, schemaName and queryName denote the following submissions. It should be noted that even + though these are the schema/query displayed in the session log, that each submission usually submits to all of the tables + listed below to build a complete room history. + + Layout editor submission: + SchemaName: cageui, QueryName: layout_history + Cage modification submission: + SchemaName: cageui, QueryName: cage_modifications_history + Rack change submission: + schemaName: cageui, QueryName: rack_history + + + */ +export interface SessionLog { + startTime: string; + userAgent: string; + schemaName: string; + queryName: string; } \ No newline at end of file diff --git a/CageUI/src/org/labkey/cageui/CageUIController.java b/CageUI/src/org/labkey/cageui/CageUIController.java index b74694552..a89834d8d 100644 --- a/CageUI/src/org/labkey/cageui/CageUIController.java +++ b/CageUI/src/org/labkey/cageui/CageUIController.java @@ -68,6 +68,7 @@ import org.labkey.cageui.model.RackSwitchOption; import org.labkey.cageui.model.RackTypes; import org.labkey.cageui.model.Room; +import org.labkey.cageui.model.SessionLog; import org.labkey.cageui.security.permissions.CageUILayoutEditorAccessPermission; import org.labkey.cageui.security.permissions.CageUIModificationEditorPermission; import org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission; @@ -376,6 +377,7 @@ public static class SaveLayoutHistoryAction extends MutatingApiAction _roomDefaultMods; + private SessionLog _sessionLog; public Room getRoom() { @@ -397,6 +399,16 @@ public void setRoomDefaultMods(ArrayList roomDefaultMods) _roomDefaultMods = roomDefaultMods; } + public SessionLog getSessionLog() + { + return _sessionLog; + } + + public void setSessionLog(SessionLog sessionLog) + { + _sessionLog = sessionLog; + } + //todo add room name validation to prevent template saving without template in the name // todo add validation to prevent room from being save with default cages, and templates being saved with real cages. @Override @@ -410,10 +422,9 @@ public void validateForm(SimpleApiJsonForm form, Errors errors) } JSONObject jsonRoom = json.getJSONObject("room"); JSONArray jsonModsArray = json.getJSONArray("mods"); + JSONObject jsonSessionLog = json.getJSONObject("sessionLog"); String prevRoomName = json.get("prevRoomName").toString(); - - ObjectMapper mapper = JsonUtil.createDefaultMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); try { @@ -465,6 +476,21 @@ public void validateForm(SimpleApiJsonForm form, Errors errors) errors.reject(ERROR_MSG, e.getMessage()); } + try + { + SessionLog sessionLog = mapper.readValue(jsonSessionLog.toString(), mapper.getTypeFactory().constructType(SessionLog.class)); + if (sessionLog != null) + { + setSessionLog(sessionLog); + }else { + errors.reject(ERROR_MSG, "Session log is corrupt"); + } + } + catch (JsonProcessingException e) + { + errors.reject(ERROR_MSG, e.getMessage()); + } + } @Override @@ -500,8 +526,19 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep ); BundledForms newSubmissionForms = submissionService.submitRoom(); + + ApiSimpleResponse res = CageUIManager.get().submitLayoutHistory(newSubmissionForms, getUser(), getContainer()); + if(res.get("success").equals(true)){ + CageUIManager.finalizeSessionLog(getSessionLog(), true, newSubmissionForms.getNewAllHistoryForm().getHistoryId()); + + CageUIManager.finalizeSessionLog(getSessionLog(), false); + }else{ + CageUIManager.finalizeSessionLog(getSessionLog(), false); + } + CageUIManager.get().submitSessionLog(getSessionLog(), getUser(), getContainer()); //return new ApiSimpleResponse(); - return CageUIManager.get().submitLayoutHistory(newSubmissionForms, getUser(), getContainer()); + return res; } + } } diff --git a/CageUI/src/org/labkey/cageui/CageUIManager.java b/CageUI/src/org/labkey/cageui/CageUIManager.java index 9a975e84d..ff7804ce4 100644 --- a/CageUI/src/org/labkey/cageui/CageUIManager.java +++ b/CageUI/src/org/labkey/cageui/CageUIManager.java @@ -21,6 +21,7 @@ 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; @@ -65,6 +66,7 @@ import org.labkey.cageui.model.RackGroup; import org.labkey.cageui.model.Room; import org.labkey.cageui.model.RoomObject; +import org.labkey.cageui.model.SessionLog; import java.sql.SQLException; import java.util.ArrayList; @@ -141,6 +143,20 @@ public List> convertToMapList(ArrayList objects) } } + // Function to finalize the session log, errors occured is true + public static SessionLog finalizeSessionLog(SessionLog oldSessionLog, Boolean errorsOccurred){ + oldSessionLog.setEndTime(new Date()); + oldSessionLog.setErrorsOccurred(errorsOccurred); + return oldSessionLog; + } + // Function to finalize the session log, errors occured is false + public static SessionLog finalizeSessionLog(SessionLog oldSessionLog, Boolean errorsOccurred, String historyId){ + oldSessionLog.setEndTime(new Date()); + oldSessionLog.setErrorsOccurred(errorsOccurred); + oldSessionLog.setTaskId(historyId); + return oldSessionLog; + } + // Helper function to wrap class object to labkeys List> for data submission public List> convertToMapList(E object) { @@ -163,6 +179,46 @@ public List> convertToMapList(E object) } } + /* + Helper function to submit the session log + */ + public ApiSimpleResponse submitSessionLog(SessionLog sessionLog, User user, Container container) throws Exception { + BatchValidationException batchErrors = new BatchValidationException(); + ApiSimpleResponse response = new ApiSimpleResponse(); + UserSchema wnprcSchema = QueryService.get().getUserSchema(user, container, "wnprc"); + + TableInfo table = wnprcSchema.getTable("session_log"); + QueryUpdateService qus = table.getUpdateService(); + if (qus == null) + { + throw new IllegalStateException(table.getName() + " query update service"); + } + + try (DbScope.Transaction tx = CageUISchema.getInstance().getSchema().getScope().ensureTransaction()) + { + + if (sessionLog != null) + { + qus.insertRows(user, container, convertToMapList(sessionLog), batchErrors, null, null); + } + + if (batchErrors.hasErrors()) + { + response.put("success", false); + response.put("errors", batchErrors); + return response; + } + tx.commit(); + response.put("success", true); + } + catch (QueryUpdateServiceException | BatchValidationException | DuplicateKeyException | RuntimeException | + SQLException e) + { + throw new ValidationException(e.getMessage()); + } + return response; + } + /* Helper function that takes the bundled forms and submits them to the appropriate tables diff --git a/CageUI/src/org/labkey/cageui/model/SessionLog.java b/CageUI/src/org/labkey/cageui/model/SessionLog.java new file mode 100644 index 000000000..089f01ee9 --- /dev/null +++ b/CageUI/src/org/labkey/cageui/model/SessionLog.java @@ -0,0 +1,127 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.labkey.cageui.model; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Date; + +public class SessionLog +{ + @JsonProperty("start_time") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm") + private Date _startTime; + @JsonProperty("end_time") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm") + private Date _endTime; + @JsonProperty("user_agent") + private String _userAgent; + @JsonProperty("schema_name") + private String _schemaName; + @JsonProperty("query_name") + private String _queryName; + @JsonProperty("task_id") + private String _taskId; + @JsonProperty("number_of_records") + private Integer _numberOfRecords; + @JsonProperty("errors_occurred") + private Boolean _errorsOccurred; + + + public Date getStartTime() + { + return _startTime; + } + + public void setStartTime(Date startTime) + { + _startTime = startTime; + } + + public Date getEndTime() + { + return _endTime; + } + + public void setEndTime(Date endTime) + { + _endTime = endTime; + } + + public String getUserAgent() + { + return _userAgent; + } + + public void setUserAgent(String userAgent) + { + _userAgent = userAgent; + } + + public String getSchemaName() + { + return _schemaName; + } + + public void setSchemaName(String schemaName) + { + _schemaName = schemaName; + } + + public String getQueryName() + { + return _queryName; + } + + public void setQueryName(String queryName) + { + _queryName = queryName; + } + + public String getTaskId() + { + return _taskId; + } + + public void setTaskId(String taskId) + { + _taskId = taskId; + } + + public Integer getNumberOfRecords() + { + return _numberOfRecords; + } + + public void setNumberOfRecords(Integer numberOfRecords) + { + _numberOfRecords = numberOfRecords; + } + + public Boolean isErrorsOccurred() + { + return _errorsOccurred; + } + + public void setErrorsOccurred(Boolean errorsOccurred) + { + _errorsOccurred = errorsOccurred; + } +} From 2f41981090ffa5b248bfce648b0d40fafb4e8969 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Wed, 8 Apr 2026 16:44:58 -0500 Subject: [PATCH 13/24] Fix bug with opening menu --- .../client/components/layoutEditor/Editor.tsx | 2 +- .../src/client/utils/LayoutEditorHelpers.ts | 37 ++++++++++--------- CageUI/src/client/utils/helpers.ts | 18 ++++++--- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index 6d1c3844c..f109debc3 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -228,7 +228,7 @@ const Editor: FC = ({roomSize}) => { } // attach context menu if user has permissions if(canOpenContextMenu(user, updateItemType)){ - setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef, setCtxMenuStyle); + setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef,"edit", setCtxMenuStyle); } dragLockRef.current = false; diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 4cbc3f7e1..38cdcbc15 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -416,6 +416,7 @@ export function setupEditCageEvent( cageGroupElement: SVGGElement, setSelectedObj: React.Dispatch>, localRoomRef: MutableRefObject, + eventType: "edit" | "view", setCtxMenuStyle?: React.Dispatch>, ): () => void { @@ -515,26 +516,26 @@ export function setupEditCageEvent( } }; - // Attach listeners - cageGroupElement.addEventListener('contextmenu', handleContextMenu); - cageGroupElement.addEventListener('touchstart', handleTouchStart); - cageGroupElement.addEventListener('touchmove', handleTouchMove); - cageGroupElement.addEventListener('touchend', handleTouchEnd); - // Optional: Also support desktop right-click directly - cageGroupElement.addEventListener('mousedown', (e) => { - if (e.button === 2) { // right click - handleContextMenu(e); - } - }); + + if (eventType === 'edit') { + cageGroupElement.addEventListener('contextmenu', handleContextMenu); + cageGroupElement.addEventListener('touchstart', handleTouchStart); + cageGroupElement.addEventListener('touchmove', handleTouchMove); + cageGroupElement.addEventListener('touchend', handleTouchEnd); + } else { + cageGroupElement.addEventListener('click', handleContextMenu); + } return () => { - cageGroupElement.removeEventListener('contextmenu', handleContextMenu); - cageGroupElement.removeEventListener('touchstart', handleTouchStart); - cageGroupElement.removeEventListener('touchmove', handleTouchMove); - cageGroupElement.removeEventListener('touchend', handleTouchEnd); - // Also remove mousedown listener if added - cageGroupElement.removeEventListener('mousedown', (e) => { if (e.button === 2) handleContextMenu(e); }); + if (eventType === 'edit') { + cageGroupElement.removeEventListener('contextmenu', handleContextMenu); + cageGroupElement.removeEventListener('touchstart', handleTouchStart); + cageGroupElement.removeEventListener('touchmove', handleTouchMove); + cageGroupElement.removeEventListener('touchend', handleTouchEnd); + } else { + cageGroupElement.removeEventListener('click', handleContextMenu); + } }; } @@ -575,7 +576,7 @@ export async function mergeRacks(props: MergeProps) { element.setAttribute('class', `grouped-${shapeType}`); element.setAttribute('style', ''); } - setupEditCageEvent(element, cageActionProps.setSelectedObj, contextMenuRef, cageActionProps.setCtxMenuStyle); + setupEditCageEvent(element, cageActionProps.setSelectedObj, contextMenuRef, "edit", cageActionProps.setCtxMenuStyle); } // add starting x and y for each group to then increment its local subgroup coords by. diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 033fb19d6..4a663b743 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -53,7 +53,8 @@ import { RoomObjectTypes, TemplateHistoryData, UnitLocations, - UnitType + UnitType, + SessionLog } from '../types/typings'; import * as d3 from 'd3'; import { zoomTransform } from 'd3'; @@ -80,6 +81,13 @@ import { cageModLookup } from '../api/popularQueries'; import { ConnectedCages, ConnectedRacks } from '../types/homeTypes'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; +// Converts JS date object to labkey java friendly date object so it can be mapped properly from JS -> Java +export const toLabKeyDate = (date: Date): string => { + const pad = (n: number, cnt: number) => n.toString().padStart(cnt, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1,2)}-${pad(date.getDate(), 2)} ` + + `${pad(date.getHours(),2)}:${pad(date.getMinutes(),2)}:${pad(date.getSeconds(),2)}.${pad(date.getMilliseconds(),3)}`; +} + export const generateCageId = (objectId: string): CageSvgId => { return `cageSVG_${objectId}` as CageSvgId; @@ -537,7 +545,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | cageGroup.append(() => shape.node()); // attach context menu if user has permissions for cages if(canOpenContextMenu(user, rack.type.type)){ - setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); } }); @@ -607,7 +615,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | placeAndScaleGroup(wrapperGroup, roomObj.x, roomObj.y, zoomTransform(layoutSvg.node())); // Attach context menu if user has permissions for room objects if(canOpenContextMenu(user, roomObj.type)){ - setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, setCtxMenuStyle); + setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); } // Attach drag functionality if user has permissions if(isDraggable(user, roomObj.type)){ @@ -1244,7 +1252,7 @@ export const findConnectedRacks = (group: RackGroup, currRack: Rack, cage?: Cage return connections; }; -export const saveRoomHelper = async (room: Room, oldTemplateName?: string, prevRackCondition?: RackConditionOption): Promise => { +export const saveRoomHelper = async (room: Room, sessionLog: SessionLog, oldTemplateName?: string, prevRackCondition?: RackConditionOption): Promise => { const newModData: CageMods[] = []; const roomName = room.name; @@ -1314,7 +1322,7 @@ export const saveRoomHelper = async (room: Room, oldTemplateName?: string, prevR let result: LayoutSaveResult; try { - const layoutSave = await saveRoomLayout(room, newModData, oldRoomName, prevRackCondition); + const layoutSave = await saveRoomLayout(room, newModData, oldRoomName,sessionLog, prevRackCondition); let errors; if (layoutSave.success === false) { errors = Array.isArray(layoutSave.errors) ? layoutSave.errors : [layoutSave.errors]; From 4e4915b4e1eac98b74dcb7db614138457c824f2a Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Wed, 8 Apr 2026 16:47:59 -0500 Subject: [PATCH 14/24] remove console logs, add console errors instead for error messages --- CageUI/src/client/api/popularQueries.ts | 2 +- CageUI/src/client/components/home/roomView/CagePopup.tsx | 1 - .../components/home/roomView/ModificationMultiSelect.tsx | 2 +- .../src/client/components/layoutEditor/GateChangeRoom.tsx | 2 +- .../client/components/layoutEditor/RoomSelectorPopup.tsx | 2 +- CageUI/src/client/context/LayoutEditorContextManager.tsx | 8 ++------ CageUI/src/client/utils/helpers.ts | 1 - 7 files changed, 6 insertions(+), 12 deletions(-) diff --git a/CageUI/src/client/api/popularQueries.ts b/CageUI/src/client/api/popularQueries.ts index af884ad03..66e55748a 100644 --- a/CageUI/src/client/api/popularQueries.ts +++ b/CageUI/src/client/api/popularQueries.ts @@ -35,7 +35,7 @@ export const cageModLookup = async (columns: string[], filterArray: Filter.IFilt if (res.rows.length !== 0) { return res.rows as EHRCageMods[]; } else { - console.log('Error cageui modifications', res); + console.error('Error cageui modifications', res); } }; diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index 046d851af..a927fff1a 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -97,7 +97,6 @@ export const CagePopup: FC = (props) => { // This submission updates the room mods with the current selections. const handleSaveMods = () => { const result = saveCageMods(currCage, currCageMods); - console.log('Submit result: ', result); if (result) { if (result.status === 'Success') { diff --git a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx index d578a7bfa..217a9b842 100644 --- a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx +++ b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx @@ -96,7 +96,7 @@ export const ModificationMultiSelect: FC = (props) setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room mods', err); + console.error('Error fetching prev room mods', err); }); }, []); diff --git a/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx b/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx index c4231920a..7ee10e537 100644 --- a/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx +++ b/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx @@ -68,7 +68,7 @@ export const GateChangeRoom: FC = (props) => { setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room', err); + console.error('Error fetching prev room', err); }); }, []); diff --git a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx index d879ca37b..26425ef3b 100644 --- a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx +++ b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx @@ -60,7 +60,7 @@ export const RoomSelectorPopup: FC = (props) => { setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room', err); + console.error('Error fetching prev room', err); }); }, []); diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index d8fbfcad4..607b9115c 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -151,10 +151,6 @@ export const LayoutEditorContextProvider: FC = ({children, p // instead of tying scale to each location, manage one scale for the whole layout const [scale, setScale] = useState(1); - useEffect(() => { - console.log("Log: ", sessionLog); - }, [sessionLog]); - const grid = useRef>(new Map()); const getCageLoc = (cageId: CageSvgId, cageNum: CageNumber): LocationCoords => { @@ -608,7 +604,7 @@ export const LayoutEditorContextProvider: FC = ({children, p // Find the moved rack to access its cages if (!movedRack) { - console.log('Failed to update cages location for rack'); + console.error('Failed to update cages location for rack'); return prevRoom; // cannot find an available rack id to move } @@ -659,7 +655,7 @@ export const LayoutEditorContextProvider: FC = ({children, p // Find the moved rack to access its cages if (movedRacks.length === 0) { - console.log('Failed to update cages location for rack'); + console.error('Failed to update cages location for rack'); return prevRoom; // cannot find an available rack id to move } setUnitLocs((prevUnitLocations) => { diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 4a663b743..2da07e390 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -418,7 +418,6 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) })); const layoutHistoryResults = await processRealLayoutHistory(layoutHistoryData); - console.log('Layout history results', layoutHistoryResults); if (layoutHistoryResults.rejected.length > 0) { throw new Error(`Error processing layout history for ${roomName}: \n ${layoutHistoryResults.rejected.join(`\n`)}`); From 06a66774ac045a0fa8dfbeb5de1c8f92406ada4e Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Thu, 9 Apr 2026 12:47:17 -0500 Subject: [PATCH 15/24] Add permissions to edit the room --- .../components/home/HomeViewContent.tsx | 3 ++- .../home/rackView/RackViewContent.tsx | 2 +- .../components/home/roomView/CagePopup.tsx | 4 ++-- .../home/roomView/ModificationMultiSelect.tsx | 3 +-- .../home/roomView/RoomViewContent.tsx | 20 +++++++++++++++---- .../client/components/layoutEditor/Editor.tsx | 4 ++-- .../pages/layoutEditor/LayoutEditor.tsx | 2 +- .../src/client/utils/LayoutEditorHelpers.ts | 16 +-------------- CageUI/src/client/utils/helpers.ts | 20 ++++++++++++++++++- CageUI/src/client/utils/homeHelpers.ts | 13 +++++++++--- 10 files changed, 55 insertions(+), 32 deletions(-) diff --git a/CageUI/src/client/components/home/HomeViewContent.tsx b/CageUI/src/client/components/home/HomeViewContent.tsx index 4053630cb..eb8784840 100644 --- a/CageUI/src/client/components/home/HomeViewContent.tsx +++ b/CageUI/src/client/components/home/HomeViewContent.tsx @@ -22,9 +22,10 @@ import '../../cageui.scss'; export const HomeViewContent: FC = () => { + // TODO possibly add instructions or another search bar here. return (
- Home Content +
); }; \ No newline at end of file diff --git a/CageUI/src/client/components/home/rackView/RackViewContent.tsx b/CageUI/src/client/components/home/rackView/RackViewContent.tsx index 7e2232330..0d3cfe1e5 100644 --- a/CageUI/src/client/components/home/rackView/RackViewContent.tsx +++ b/CageUI/src/client/components/home/rackView/RackViewContent.tsx @@ -24,7 +24,7 @@ import { RackDetails } from './RackDetails'; import { CagesOverview } from './CagesOverview'; import { ChangeRackPopup } from './ChangeRackPopup'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; -import { isRoomModifier } from '../../../utils/LayoutEditorHelpers'; +import { isRoomModifier } from '../../../utils/helpers'; export const RackViewContent: FC = () => { const {selectedRoom, selectedRack, userProfile} = useHomeNavigationContext(); diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index a927fff1a..120f99453 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -22,11 +22,11 @@ import '../../../cageui.scss'; import { ModificationEditor } from './ModificationEditor'; import { SelectedObj } from '../../../types/layoutEditorTypes'; import { Cage, CurrCageMods, Rack } from '../../../types/typings'; -import { findCageInGroup, isCageModifier } from '../../../utils/LayoutEditorHelpers'; +import { findCageInGroup } from '../../../utils/LayoutEditorHelpers'; import { useRoomContext } from '../../../context/RoomContextManager'; import { Button } from 'react-bootstrap'; import { AnimalEditor } from './AnimalEditor'; -import { formatCageNum } from '../../../utils/helpers'; +import { formatCageNum, isCageModifier } from '../../../utils/helpers'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; interface CagePopupProps { diff --git a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx index 217a9b842..132ad4ebe 100644 --- a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx +++ b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx @@ -22,8 +22,7 @@ import { ModDirections, ModTypes } from '../../../types/typings'; import { Filter } from '@labkey/api'; import { ConnectedModType, EHRCageMods } from '../../../types/homeTypes'; import { cageModLookup } from '../../../api/popularQueries'; -import { generateUUID } from '../../../utils/helpers'; -import { isCageModifier } from '../../../utils/LayoutEditorHelpers'; +import { generateUUID, isCageModifier } from '../../../utils/helpers'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; interface ModificationMultiSelectProps { diff --git a/CageUI/src/client/components/home/roomView/RoomViewContent.tsx b/CageUI/src/client/components/home/roomView/RoomViewContent.tsx index eb2ca0a98..de9cf13c9 100644 --- a/CageUI/src/client/components/home/roomView/RoomViewContent.tsx +++ b/CageUI/src/client/components/home/roomView/RoomViewContent.tsx @@ -24,12 +24,14 @@ import { SubViewContent } from '../SubViewContent'; import { RoomDetails } from './RoomDetails'; import { RoomLayout } from './RoomLayout'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { Button } from 'react-bootstrap'; +import { canEditLayout } from '../../../utils/homeHelpers'; interface RoomViewContentProps { } export const RoomViewContent: FC = (props) => { - const {selectedPage, selectedRoom} = useHomeNavigationContext(); + const {selectedPage, selectedRoom, userProfile} = useHomeNavigationContext(); const roomName = selectedPage?.room; const handleLayoutEdit = () => { @@ -43,15 +45,25 @@ export const RoomViewContent: FC = (props) => { selectedPage &&
+ {/* Hide room valid for now, it could be misleading until we add room validations + />*/} {roomName} + + {canEditLayout(userProfile) && + + }
= (props) => {
{roomName} does not have an existing layout.
- }, { + }, /*{ Hide RoomDetails for now since it is currently not used. name: 'Details', children: - } + }*/ ]} />
diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index f109debc3..c997b2da4 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -59,8 +59,6 @@ import { getLayoutOffset, getTargetRect, isDraggable, isRackEnum, - isRoomCreator, - isTemplateCreator, mergeRacks, parseWrapperId, placeAndScaleGroup, @@ -75,6 +73,8 @@ import { parseRoomItemNum, parseRoomItemType, roomItemToString, + isRoomCreator, + isTemplateCreator, stringToRoomItem } from '../../utils/helpers'; import { SelectorOptions } from './RoomSizeSelector'; diff --git a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx index 098693503..fd0de7825 100644 --- a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx +++ b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx @@ -27,7 +27,7 @@ import Editor from '../../components/layoutEditor/Editor'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { RoomSizeSelector, SelectorOptions } from '../../components/layoutEditor/RoomSizeSelector'; import { ConfirmationPopup } from '../../components/ConfirmationPopup'; -import { isRoomCreator, isRoomModifier, isTemplateCreator } from '../../utils/LayoutEditorHelpers'; +import { isRoomCreator, isRoomModifier, isTemplateCreator } from '../../utils/helpers'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; import { roomSizeOptions } from '../../utils/constants'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 38cdcbc15..8faa49ac4 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -23,7 +23,7 @@ import { generateUUID, getAdjLocation, getDefaultMod, - getTypeClassFromElement, + getTypeClassFromElement, isRoomCreator, isRoomModifier, isTemplateCreator, parseRoomItemType, roomItemToString } from './helpers'; @@ -69,21 +69,7 @@ import { fetchCage, fetchCageHistory, fetchRack } from '../api/popularQueries'; import { ConnectedCage, ConnectedRack } from '../types/homeTypes'; -export const isTemplateCreator = (user: GetUserPermissionsResponse) => { - return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUITemplateCreatorPermission'); -}; - -export const isRoomCreator = (user: GetUserPermissionsResponse) => { - return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission'); -}; -export const isRoomModifier = (user: GetUserPermissionsResponse) => { - return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIRoomModifierPermission'); -}; - -export const isCageModifier = (user: GetUserPermissionsResponse) => { - return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIModificationEditorPermission'); -}; export const isTouchEvent = (event)=> { return event.type.startsWith('touch'); diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 2da07e390..d6e9f7bc7 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -59,7 +59,7 @@ import { import * as d3 from 'd3'; import { zoomTransform } from 'd3'; import { MutableRefObject } from 'react'; -import { ActionURL, Filter, Utils } from '@labkey/api'; +import { ActionURL, Filter, Security, Utils } from '@labkey/api'; import { addModEntries, areAllRacksNonDefault, canOpenContextMenu, @@ -81,6 +81,24 @@ import { cageModLookup } from '../api/popularQueries'; import { ConnectedCages, ConnectedRacks } from '../types/homeTypes'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; + +export const isTemplateCreator = (user: GetUserPermissionsResponse) => { + return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUITemplateCreatorPermission'); +}; + +export const isRoomCreator = (user: GetUserPermissionsResponse) => { + return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIRoomCreatorPermission'); +}; + +export const isRoomModifier = (user: GetUserPermissionsResponse) => { + return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIRoomModifierPermission'); +}; + +export const isCageModifier = (user: GetUserPermissionsResponse) => { + return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIModificationEditorPermission'); +}; + + // Converts JS date object to labkey java friendly date object so it can be mapped properly from JS -> Java export const toLabKeyDate = (date: Date): string => { const pad = (n: number, cnt: number) => n.toString().padStart(cnt, '0'); diff --git a/CageUI/src/client/utils/homeHelpers.ts b/CageUI/src/client/utils/homeHelpers.ts index d029f307c..33c48fac4 100644 --- a/CageUI/src/client/utils/homeHelpers.ts +++ b/CageUI/src/client/utils/homeHelpers.ts @@ -26,11 +26,18 @@ import { ModTypes, RoomMods } from '../types/typings'; -import { Option } from '@labkey/components'; -import { cageModLookup } from '../api/popularQueries'; -import { parseRoomItemNum, parseRoomItemType } from './helpers'; +import { isRoomCreator, isRoomModifier, isTemplateCreator, parseRoomItemNum, parseRoomItemType } from './helpers'; +import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; +// Determines if the user has access to editing the layout +export const canEditLayout = (user: GetUserPermissionsResponse) => { + if(isRoomCreator(user) || isTemplateCreator(user) || isRoomModifier(user)) { + return true; + } + return false; +} + // takes a cage number and returns it in a display friendly format, ex: cage-1 -> Cage 1 export const getCageNumDisplay = (cageNum: CageNumber) => { return `${parseRoomItemType(cageNum).charAt(0).toUpperCase() + parseRoomItemType(cageNum).slice(1)} ${parseRoomItemNum(cageNum)}`; From 2fb94f21bed7e01958d5af3fde861433b14f721f Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Thu, 9 Apr 2026 13:22:01 -0500 Subject: [PATCH 16/24] Add restraint modification and window blind modification --- CageUI/resources/web/CageUI/static/cage.svg | 9 ++++++ CageUI/src/client/types/typings.ts | 7 ++-- CageUI/src/client/utils/constants.ts | 36 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CageUI/resources/web/CageUI/static/cage.svg b/CageUI/resources/web/CageUI/static/cage.svg index 672c948a4..29d6d884c 100644 --- a/CageUI/resources/web/CageUI/static/cage.svg +++ b/CageUI/resources/web/CageUI/static/cage.svg @@ -29,6 +29,15 @@ + + + + + + diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index 26ef50f9d..520b48010 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -72,7 +72,9 @@ export enum ModTypes { NoDivider = 'nd', CTunnel = 'ct', Extension = 'ex', - SPDivider = 'spd' // Social Panel + SPDivider = 'spd', // Social Panel + Restraint = 'res', + Blind = 'bld' } export enum ModDirections { @@ -114,6 +116,8 @@ export enum ModSvgLocId { Top = 'ceiling', Bottom = 'floor', Extension = 'extension', + Restraint = 'restraint', + Blind = 'blind', CTunnelCircle = 'cTunnel-circle', CTunnelLeft = 'cTunnel-left', CTunnelRight = 'cTunnel-right', @@ -125,7 +129,6 @@ export enum ModSvgLocId { export enum RackConditions { Operational, Damaged, - Repairing, } export type RackStringType = string & { __brand: 'RackStringType' }; diff --git a/CageUI/src/client/utils/constants.ts b/CageUI/src/client/utils/constants.ts index 108ed7a29..8ef19e5cf 100644 --- a/CageUI/src/client/utils/constants.ts +++ b/CageUI/src/client/utils/constants.ts @@ -282,5 +282,41 @@ export const Modifications: ModRecord = { property: 'fill', value: '#FCB017' }] + }, + [ModTypes.Restraint]: { + name: 'Restraint', + svgIds: { + [ModLocations.Direct]: { + [GroupRotation.Origin]: [ModSvgLocId.Restraint], + [GroupRotation.Quarter]: [ModSvgLocId.Restraint], + [GroupRotation.Half]: [ModSvgLocId.Restraint], + [GroupRotation.ThreeQuarter]: [ModSvgLocId.Restraint] + }, + }, + styles: [{ + property: 'stroke', + value: 'black' + }, { + property: 'stroke-width', + value: '1px' + }, { + property: 'fill', + value: '#FF0000' + }] + }, + [ModTypes.Blind]: { + name: 'Window Blind', + svgIds: { + [ModLocations.Direct]: { + [GroupRotation.Origin]: [ModSvgLocId.Blind], + [GroupRotation.Quarter]: [ModSvgLocId.Blind], + [GroupRotation.Half]: [ModSvgLocId.Blind], + [GroupRotation.ThreeQuarter]: [ModSvgLocId.Blind] + }, + }, + styles: [{ + property: 'opacity', + value: '100' + }] } }; \ No newline at end of file From c07c996e89fa71ae231395a8b2a6d5a9a4f47bb8 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Mon, 13 Apr 2026 15:38:10 -0500 Subject: [PATCH 17/24] Add room object context menu and gate saving --- CageUI/src/client/cageui.scss | 214 ++++++++++++++---- .../home/cageView/CageViewContent.tsx | 4 +- .../home/cageView/CurrentCageLayout.tsx | 4 +- .../home/rackView/RackViewContent.tsx | 4 +- .../home/roomView/CageModifications.tsx | 24 +- .../components/home/roomView/CagePopup.tsx | 28 ++- .../components/home/roomView/GateEditor.tsx | 72 ++++++ .../components/home/roomView/RoomLayout.tsx | 88 ++++--- .../home/roomView/RoomObjectPopup.tsx | 119 ++++++++++ .../home/roomView/RoomViewContent.tsx | 4 +- .../context/HomeNavigationContextManager.tsx | 10 +- .../src/client/context/RoomContextManager.tsx | 31 ++- .../types/homeNavigationContextTypes.ts | 4 +- CageUI/src/client/types/roomContextTypes.ts | 6 +- .../src/client/utils/LayoutEditorHelpers.ts | 2 +- CageUI/src/client/utils/helpers.ts | 36 ++- 16 files changed, 516 insertions(+), 134 deletions(-) create mode 100644 CageUI/src/client/components/home/roomView/GateEditor.tsx create mode 100644 CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx diff --git a/CageUI/src/client/cageui.scss b/CageUI/src/client/cageui.scss index 6c403b98b..451242c3d 100644 --- a/CageUI/src/client/cageui.scss +++ b/CageUI/src/client/cageui.scss @@ -398,7 +398,7 @@ } .button-84:focus { - box-shadow: rgba(0, 0, 0, .5) 0 0 0 3px; + box-shadow: 0 0 0 3px rgba(0, 0, 0, .5); } @media (max-width: 420px) { @@ -1475,7 +1475,7 @@ margin-top: 0px; background-color: lightblue; } -.cage-popup-overlay { +.room-display-popup-overlay { position: fixed; display: flex; top: 0; @@ -1492,7 +1492,7 @@ margin-top: 0px; touch-action: none; } -.cage-popup { +.room-display-popup { position: relative; z-index: 1000; background: white; @@ -1503,21 +1503,20 @@ margin-top: 0px; animation: fadeIn 0.2s ease-out; } -.cage-popup-header { +.room-display-popup-header { display: flex; - justify-content: space-between; align-items: center; margin-bottom: 16px; } -.cage-popup-title { +.room-display-popup-title { flex: 1; font-weight: lighter; text-align: center; margin: 0; } -.cage-popup-close { +.room-display-popup-close { background: none; border: none; font-size: 4rem; @@ -1527,81 +1526,204 @@ margin-top: 0px; line-height: 1; } -.cage-popup-close:hover { +.room-display-popup-close:hover { color: #333; } -.cage-popup-content { - margin-bottom: 20px; - display: flex; - gap: 10px; - flex-direction: row; -} - - -.modification-editor { - -} - -.modification-editor-title { - padding-bottom: 5px; - padding-top: 5px; - border-bottom: lightgrey 5px solid; -} - -.modification-editor-input { - width: 100%; - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1rem; -} -.modification-editor-content { +.room-display-popup-content { margin-bottom: 20px; display: flex; gap: 10px; flex-direction: row; } -.cage-popup-actions { +.room-display-popup-actions { display: flex; height: fit-content; gap: 10px; } -.cage-popup-button { +.room-display-popup-button { padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 1.1rem; } -.cage-popup-save { +.room-display-popup-save { background: #4CAF50; color: white; border: none; } -.cage-popup-error { +.room-display-popup-error { color: red; flex: 1; } -.cage-popup-save:hover { +.room-display-popup-save:hover { color: #45a049; } -.cage-popup-cancel { +.room-display-popup-cancel { border: 1px solid #ddd; color: #333; } -.cage-popup-cancel:hover { +.room-display-popup-cancel:hover { background-color: #e7e7e7; } +.gate-editor { + display: flex; + flex-direction: column; + gap: 20px; /* Spacing between rows */ + width: 100%; + margin-bottom: 40px; + margin-top: 40px; +} + +.gate-editor-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 100px; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; +} + +.gate-editor-row:last-of-type { + border-bottom: none; +} + +.gate-editor-row-label { + font-weight: 600; + color: #333; + font-size: medium; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + width: 80px; +} + +.gate-editor-row-value { + flex-grow: 1; + margin-left: 40px; /* Ample spacing from label */ + font-size: medium; + text-align: right; +} + +/* Base button style */ +.gate-editor-status-btn { + /* Reset & modern styling */ + padding: 0.6rem 1.2rem; + border-radius: 6px; + border: 1px solid transparent; + background-color: var(--status-bg, #e0e0e0); /* fallback */ + color: var(--status-text, #333); + font-size: medium; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + min-width: 90px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5ch; + +} + +/* 🔴 Closed state */ +.gate-editor-status-btn[data-status='closed'] { + background-color: #e53935; /* Material red-500 */ + color: white; + border-color: #b71c1c; + box-shadow: 0 2px 6px rgba(229, 57, 53, 0.25); +} + +.gate-editor-status-btn[data-status='closed']:hover { + background-color: #d32f2f; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(229, 57, 53, 0.35); +} + +.gate-editor-status-btn[data-status='closed']:active { + transform: translateY(1px); + box-shadow: 0 1px 3px rgba(229, 57, 53, 0.25); +} + +/* 🔵 Open state */ +.gate-editor-status-btn[data-status='open'] { + background-color: #1976d2; /* Material blue-700 */ + color: white; + border-color: #0d47a1; + box-shadow: 0 2px 6px rgba(25, 118, 210, 0.25); +} + +.gate-editor-status-btn[data-status='open']:hover { + background-color: #1565c0; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(25, 118, 210, 0.35); +} + +.gate-editor-status-btn[data-status='open']:active { + transform: translateY(1px); + box-shadow: 0 1px 3px rgba(25, 118, 210, 0.25); +} + +/* Optional: Add a subtle status dot (green/red glow) */ +.gate-editor-status-btn[data-status='closed']::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #b71c1c; + box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.2); +} + +.gate-editor-status-btn[data-status='open']::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #42a5f5; + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); +} + + + + +.modification-editor { + +} + +.modification-editor-title { + padding-bottom: 5px; + padding-top: 5px; + border-bottom: lightgrey 5px solid; +} + +.modification-editor-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} +.modification-editor-content { + margin-bottom: 20px; + display: flex; + gap: 10px; + flex-direction: row; +} + + + + @keyframes fadeIn { from { opacity: 0; @@ -1756,7 +1878,7 @@ Multi Dropdown Css background-color: #fff; border: 1px solid #d5d9d9; border-radius: 8px; - box-shadow: rgba(213, 217, 217, .5) 0 2px 5px 0; + box-shadow: 0 2px 5px 0 rgba(213, 217, 217, .5); box-sizing: border-box; color: #0f1111; cursor: pointer; @@ -1781,7 +1903,7 @@ Multi Dropdown Css .room-layout-save-btn:focus { border-color: #008296; - box-shadow: rgba(213, 217, 217, .5) 0 2px 5px 0; + box-shadow: 0 2px 5px 0 rgba(213, 217, 217, .5) ; outline: 0; } @@ -2015,7 +2137,7 @@ Multi Dropdown Css /* iPad-specific adjustments (landscape) */ @media (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) { - .cage-popup { + .room-display-popup { padding: 30px; width: 80%; height: 90%; @@ -2036,12 +2158,12 @@ Multi Dropdown Css /* iPad-specific adjustments (portrait) */ @media (min-width: 768px) and (max-width: 1024px) and (orientation: portrait) { - .cage-popup-overlay{ + .room-display-popup-overlay{ touch-action: none; height: 100vh; transform: translateZ(0); } - .cage-popup { + .room-display-popup { max-width: 70dvh; max-height: 90vh; overflow: auto; diff --git a/CageUI/src/client/components/home/cageView/CageViewContent.tsx b/CageUI/src/client/components/home/cageView/CageViewContent.tsx index a182b6d6d..b68cd5c00 100644 --- a/CageUI/src/client/components/home/cageView/CageViewContent.tsx +++ b/CageUI/src/client/components/home/cageView/CageViewContent.tsx @@ -28,7 +28,7 @@ import { CageDetails } from './CageDetails'; import { getCageNumDisplay } from '../../../utils/homeHelpers'; export const CageViewContent: FC = () => { - const {selectedCage, selectedRoom, selectedRack} = useHomeNavigationContext(); + const {selectedCage, selectedLocalRoom, selectedRack} = useHomeNavigationContext(); const [cageDimensions, setCageDimensions] = useState(null); useEffect(() => { @@ -58,7 +58,7 @@ export const CageViewContent: FC = () => { return ( selectedCage &&
+ key={'layout-' + selectedLocalRoom + '-rack-' + selectedRack.itemId + '-' + selectedCage.cageNum}>
{getCageNumDisplay(selectedCage.cageNum)} diff --git a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx index f05dfb9a5..74cdfbf0f 100644 --- a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx +++ b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx @@ -31,7 +31,7 @@ interface CurrentCageLayoutProps { export const CurrentCageLayout: FC = (props) => { const {cage} = props; - const {selectedRoom, userProfile} = useHomeNavigationContext(); + const {selectedLocalRoom, userProfile} = useHomeNavigationContext(); const cageRef = useRef(null); @@ -45,7 +45,7 @@ export const CurrentCageLayout: FC = (props) => { const element = d3.select(this) as d3.Selection; element.remove(); }); - addPrevRoomSvgs(userProfile, 'view', cage, cageSvg, selectedRoom, selectedRoom.mods); + addPrevRoomSvgs(userProfile, 'view', cage, cageSvg, selectedLocalRoom, selectedLocalRoom.mods); }, [cage]); // adding 1 to the width/height helps make sure the lines don't get cut off in the image diff --git a/CageUI/src/client/components/home/rackView/RackViewContent.tsx b/CageUI/src/client/components/home/rackView/RackViewContent.tsx index 0d3cfe1e5..867ad179c 100644 --- a/CageUI/src/client/components/home/rackView/RackViewContent.tsx +++ b/CageUI/src/client/components/home/rackView/RackViewContent.tsx @@ -27,7 +27,7 @@ import { useHomeNavigationContext } from '../../../context/HomeNavigationContext import { isRoomModifier } from '../../../utils/helpers'; export const RackViewContent: FC = () => { - const {selectedRoom, selectedRack, userProfile} = useHomeNavigationContext(); + const {selectedLocalRoom, selectedRack, userProfile} = useHomeNavigationContext(); const [showChangeRackPopup, setShowChangeRackPopup] = useState(false); const handleRackChange = () => { @@ -36,7 +36,7 @@ export const RackViewContent: FC = () => { return ( selectedRack && -
+
Rack {selectedRack.itemId} diff --git a/CageUI/src/client/components/home/roomView/CageModifications.tsx b/CageUI/src/client/components/home/roomView/CageModifications.tsx index 860b0790f..5dd98032d 100644 --- a/CageUI/src/client/components/home/roomView/CageModifications.tsx +++ b/CageUI/src/client/components/home/roomView/CageModifications.tsx @@ -42,7 +42,7 @@ interface CageModificationsProps { export const CageModifications: FC = (props) => { const {cage, rack, currCageMods, setCurrCageMods} = props; - const {selectedRoom} = useHomeNavigationContext(); + const {selectedLocalRoom} = useHomeNavigationContext(); const [rackGroup, setRackGroup] = useState(null); const [connectedCages, setConnectedCages] = useState(null); const [aloneCages, setAloneCages] = useState(null); @@ -50,7 +50,7 @@ export const CageModifications: FC = (props) => { // Find possible connects useEffect(() => { - const {rackGroup: currGroup, rack: currRack} = findCageInGroup(cage.svgId, selectedRoom.rackGroups); + const {rackGroup: currGroup, rack: currRack} = findCageInGroup(cage.svgId, selectedLocalRoom.rackGroups); const connectionsObj = findConnectedCages(currRack, currGroup.rotation, cage); // connect prev cages @@ -64,8 +64,8 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.currMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedRoom.mods[key.modId].label, - value: selectedRoom.mods[key.modId].value, + label: selectedLocalRoom.mods[key.modId].label, + value: selectedLocalRoom.mods[key.modId].value, modId: key.modId, parentModId: key.parentModId }; @@ -81,8 +81,8 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.adjMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedRoom.mods[key.modId].label, - value: selectedRoom.mods[key.modId].value, + label: selectedLocalRoom.mods[key.modId].label, + value: selectedLocalRoom.mods[key.modId].value, modId: key.modId, parentModId: key.parentModId }; @@ -114,8 +114,8 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.currMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedRoom.mods[key.modId].label, - value: selectedRoom.mods[key.modId].value, + label: selectedLocalRoom.mods[key.modId].label, + value: selectedLocalRoom.mods[key.modId].value, modId: key.modId, parentModId: key.parentModId }; @@ -131,8 +131,8 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.adjMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedRoom.mods[key.modId].label, - value: selectedRoom.mods[key.modId].value, + label: selectedLocalRoom.mods[key.modId].label, + value: selectedLocalRoom.mods[key.modId].value, modId: key.modId, parentModId: key.parentModId }; @@ -256,8 +256,8 @@ export const CageModifications: FC = (props) => { handleChange={(selectedItems) => handleChange(ModLocations.Direct, cage, selectedItems)} prevItems={cage.mods[ModLocations.Direct].flatMap(subMods => { return subMods.modKeys.map(key => ({ - label: selectedRoom.mods[key.modId].label, - value: selectedRoom.mods[key.modId].value, + label: selectedLocalRoom.mods[key.modId].label, + value: selectedLocalRoom.mods[key.modId].value, modId: key.modId })); })} diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index 120f99453..d8d6c67ef 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -30,19 +30,17 @@ import { formatCageNum, isCageModifier } from '../../../utils/helpers'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; interface CagePopupProps { - showEditor: boolean; selectedObj: SelectedObj; closeMenu: () => void; } export const CagePopup: FC = (props) => { const { - showEditor, closeMenu, selectedObj, } = props; const {saveCageMods} = useRoomContext(); - const {selectedRoom, userProfile} = useHomeNavigationContext(); + const {selectedLocalRoom, userProfile} = useHomeNavigationContext(); const [currCage, setCurrCage] = useState(null); const [currRack, setCurrRack] = useState(null); @@ -54,7 +52,7 @@ export const CagePopup: FC = (props) => { useEffect(() => { const tempCage = selectedObj as Cage; if (tempCage) { - const cageRack = findCageInGroup(tempCage.svgId, selectedRoom.rackGroups).rack; + const cageRack = findCageInGroup(tempCage.svgId, selectedLocalRoom.rackGroups).rack; setCurrCage(tempCage); setCurrRack(cageRack); } @@ -108,12 +106,12 @@ export const CagePopup: FC = (props) => { }; return ( - showEditor && -
-
-
-

{formatCageNum(currCage.cageNum)}

- + currCage && +
+
+
+

{formatCageNum(currCage.cageNum)}

+
= (props) => { /> -
-
+
+
{showError}
-
- {isCageModifier(userProfile) && + +
+
+ ) +} \ No newline at end of file diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index 3eda117cb..b5b9e9b85 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -21,8 +21,8 @@ import { FC, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { ActionURL } from '@labkey/api'; import { ReactSVG } from 'react-svg'; -import { Cage } from '../../../types/typings'; -import { addPrevRoomSvgs } from '../../../utils/helpers'; +import { Cage, Room } from '../../../types/typings'; +import { addPrevRoomSvgs, isRoomModifier } from '../../../utils/helpers'; import { findCageInGroup, updateBorderSize } from '../../../utils/LayoutEditorHelpers'; import { ConfirmationPopup } from '../../ConfirmationPopup'; import _ from 'lodash'; @@ -33,63 +33,80 @@ import { LoadingScreen } from '../../LoadingScreen'; import { RoomLegend } from './RoomLegend'; import { CagePopup } from './CagePopup'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { RoomObjectPopup } from './RoomObjectPopup'; interface RoomLayoutProps { } export const RoomLayout: FC = (props) => { const {submitLayoutMods} = useRoomContext(); - const {selectedRoom, selectedRoomMods, navigateTo, userProfile} = useHomeNavigationContext(); + const {selectedLocalRoom, selectedRoomMods, navigateTo, userProfile, selectedRoom} = useHomeNavigationContext(); const [selectedContextObj, setSelectedContextObj] = useState(null); const [showCageContextMenu, setShowCageContextMenu] = useState(false); + const [showObjContextMenu, setShowObjContextMenu] = useState(false); const [showChangesMenu, setShowChangesMenu] = useState(false); const [errorPopup, setErrorPopup] = useState(null); const [showLayoutErrors, setShowLayoutErrors] = useState([]); const [isSaving, setIsSaving] = useState(false); const borderRef = useRef(null); - const contextRef = useRef(selectedRoom); + const contextRef = useRef(selectedLocalRoom); + + useEffect(() => { + console.log("Selected Room: ", selectedLocalRoom); + }, [selectedLocalRoom]); // Loads room into the svg useEffect(() => { - if (!selectedRoom.name) { + if (!selectedLocalRoom.name) { return; } - if (showCageContextMenu) { + if (showCageContextMenu || showObjContextMenu) { return; } d3.select('#layout-svg').selectAll('*:not(#layout-border, #layout-border *)').remove(); const layoutSvg = d3.select('#layout-svg') as d3.Selection; - contextRef.current = selectedRoom; - addPrevRoomSvgs(userProfile,'view', selectedRoom, layoutSvg,undefined, selectedRoom.mods, setSelectedContextObj, contextRef); - }, [selectedRoom.name, showCageContextMenu]); + contextRef.current = selectedLocalRoom; + addPrevRoomSvgs(userProfile,'view', selectedLocalRoom, layoutSvg,undefined, selectedLocalRoom.mods, setSelectedContextObj, contextRef); + }, [selectedLocalRoom.name, showCageContextMenu, showObjContextMenu]); // Effect watches for right clicks to open the modification editor useEffect(() => { if (selectedContextObj) { - const currRackDefault = findCageInGroup((selectedContextObj as Cage).svgId, selectedRoom.rackGroups).rack.type.isDefault; - if (currRackDefault) { - setErrorPopup('This cage is a default cage and as such it cannot have mods attached. Please only attach mods to real cages'); - } else { - setShowCageContextMenu(true); + if(selectedContextObj.selectionType === 'obj'){ + setShowObjContextMenu(true); + }else{ + const currRackDefault = findCageInGroup((selectedContextObj as Cage).svgId, selectedLocalRoom.rackGroups).rack.type.isDefault; + if (currRackDefault) { + setErrorPopup('This cage is a default cage and as such it cannot have mods attached. Please only attach mods to real cages'); + } else { + setShowCageContextMenu(true); + } } } }, [selectedContextObj]); // Cleans up selected object after modification editor is closed useEffect(() => { - if (showCageContextMenu) { + if (showCageContextMenu || showObjContextMenu) { return; } setSelectedContextObj(null); - }, [showCageContextMenu]); + }, [showCageContextMenu, showObjContextMenu]); + + useEffect(() => { + if (!selectedLocalRoom.mods || !selectedRoomMods) { + return; + } + setShowChangesMenu(!(_.isEqual(selectedRoomMods, selectedLocalRoom.mods))); + }, [selectedLocalRoom.mods]); useEffect(() => { - if (!selectedRoom.mods || !selectedRoomMods) { + if (!selectedRoom || selectedLocalRoom.objects.length === 0) { return; } - setShowChangesMenu(!(_.isEqual(selectedRoomMods, selectedRoom.mods))); - }, [selectedRoom.mods]); + setShowChangesMenu(!(_.isEqual(selectedRoom.objects, selectedLocalRoom.objects))); + }, [selectedLocalRoom.objects]); const saveLayout = async () => { @@ -99,7 +116,7 @@ export const RoomLayout: FC = (props) => { if (res.success) { // succssesful save setIsSaving(false); - navigateTo({selected: 'Room', room: selectedRoom.name}); + navigateTo({selected: 'Room', room: selectedLocalRoom.name}); } else { if (res?.reason) { setShowLayoutErrors(res.reason); @@ -141,9 +158,9 @@ export const RoomLayout: FC = (props) => {
= (props) => { key={'border_template_key'} ref={borderRef} className={''} - viewBox={`0 0 ${selectedRoom.layoutData.borderWidth} ${selectedRoom.layoutData.borderHeight}`} - height={selectedRoom.layoutData.borderHeight} - width={selectedRoom.layoutData.borderWidth} + viewBox={`0 0 ${selectedLocalRoom.layoutData.borderWidth} ${selectedLocalRoom.layoutData.borderHeight}`} + height={selectedLocalRoom.layoutData.borderHeight} + width={selectedLocalRoom.layoutData.borderWidth} pointerEvents={'none'} afterInjection={(svg) => { const borderGroup = d3.select('#layout-border') as d3.Selection; - updateBorderSize(borderGroup, selectedRoom.layoutData.borderWidth, selectedRoom.layoutData.borderHeight); + updateBorderSize(borderGroup, selectedLocalRoom.layoutData.borderWidth, selectedLocalRoom.layoutData.borderHeight); }} />
- setShowCageContextMenu(false)} - /> + {showCageContextMenu && + setShowCageContextMenu(false)} + /> + } + {(showObjContextMenu && isRoomModifier(userProfile)) && + setShowObjContextMenu(false)} + /> + } {errorPopup && setErrorPopup(null)}/> } diff --git a/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx new file mode 100644 index 000000000..748a58de8 --- /dev/null +++ b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx @@ -0,0 +1,119 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ +import * as React from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; +import { SelectedObj } from '../../../types/layoutEditorTypes'; +import { formatRoomObj, isRoomModifier } from '../../../utils/helpers'; +import { RoomObject, RoomObjectTypes } from '../../../types/typings'; +import { Button } from 'react-bootstrap'; +import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { GateEditor } from './GateEditor'; +import { useRoomContext } from '../../../context/RoomContextManager'; + +interface CagePopupProps { + selectedObj: SelectedObj; + closeMenu: () => void; +} + +export const RoomObjectPopup: FC = (props) => { + const { + closeMenu, + selectedObj, + } = props; + + const {userProfile} = useHomeNavigationContext(); + const {saveRoomObj} = useRoomContext(); + + const [roomObj, setRoomObj] = useState(selectedObj as RoomObject); + 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) => { + // Ignore dropdowns that disappear causing them to no longer be in menuRef + if (event.target.closest('[class*="indicatorContainer"]')) { + return; + } + // Ignore popup buttons that are an additional popup but shouldn't close the original popup + if (event.target.tagName.toLowerCase() === 'button') { + return; + } + // if the target is outside the modification editor menu ref close the editor + if (menuRef.current && !menuRef.current.contains(event.target)) { + closeMenu(); + } + }; + + // Add event listener to detect clicks + document.addEventListener('mousedown', handleClickOutside); + + // Cleanup event listener on component unmount + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [menuRef]); + + + const handleCleanup = () => { + closeMenu(); + }; + + // This submission updates the room mods with the current selections. + const handleSave = () => { + saveRoomObj(prevRoomObjId, roomObj); + handleCleanup(); + }; + + return ( +
+
+
+

{formatRoomObj(roomObj.itemId)}

+ +
+ {(roomObj.type === RoomObjectTypes.GateOpen || RoomObjectTypes.GateClosed) && + + } +
+
+
+
+ + {isRoomModifier(userProfile) && + + } +
+
+
+
+ ); +} \ No newline at end of file diff --git a/CageUI/src/client/components/home/roomView/RoomViewContent.tsx b/CageUI/src/client/components/home/roomView/RoomViewContent.tsx index de9cf13c9..7f76826fc 100644 --- a/CageUI/src/client/components/home/roomView/RoomViewContent.tsx +++ b/CageUI/src/client/components/home/roomView/RoomViewContent.tsx @@ -31,7 +31,7 @@ interface RoomViewContentProps { } export const RoomViewContent: FC = (props) => { - const {selectedPage, selectedRoom, userProfile} = useHomeNavigationContext(); + const {selectedPage, selectedLocalRoom, userProfile} = useHomeNavigationContext(); const roomName = selectedPage?.room; const handleLayoutEdit = () => { @@ -69,7 +69,7 @@ export const RoomViewContent: FC = (props) => { tabs={[{ name: 'Layout', children: - selectedRoom ? + selectedLocalRoom ? :
diff --git a/CageUI/src/client/context/HomeNavigationContextManager.tsx b/CageUI/src/client/context/HomeNavigationContextManager.tsx index 0b4177c06..eace2551b 100644 --- a/CageUI/src/client/context/HomeNavigationContextManager.tsx +++ b/CageUI/src/client/context/HomeNavigationContextManager.tsx @@ -51,13 +51,16 @@ export const HomeNavigationContextProvider: FC = ({u const [userProfile, setUserProfile] = useState(user); const [selectedRoom, setSelectedRoom] = useState(null); + const [selectedLocalRoom, setSelectedLocalRoom] = useState(null); const [selectedRoomMods, setSelectedRoomMods] = useState({}); const [selectedRackGroup, setSelectedRackGroup] = useState(null); const [selectedRack, setSelectedRack] = useState(null); const [selectedCage, setSelectedCage] = useState(null); - // Track if we've already handled this specific URL + useEffect(() => { + setSelectedLocalRoom(selectedRoom); + }, [selectedRoom]); // Load initial data based on URL parameters useEffect(() => { @@ -197,7 +200,7 @@ export const HomeNavigationContextProvider: FC = ({u newLocalRoom.layoutData = roomData.prevRoomData.layoutData; // Ensure they don't share the same reference (using lodash to clone) setSelectedRoomMods(_.cloneDeep(newLocalRoom.mods)); - setSelectedRoom(newLocalRoom); + setSelectedRoom({...newLocalRoom, objects: [...newLocalRoom.objects]}); } return newLocalRoom; } else { @@ -223,10 +226,11 @@ export const HomeNavigationContextProvider: FC = ({u selectedRoomMods, selectedRackGroup, selectedRoom, + selectedLocalRoom, selectedRack, selectedCage, navigateTo, - setSelectedRoom, + setSelectedLocalRoom, userProfile, }}> {children} diff --git a/CageUI/src/client/context/RoomContextManager.tsx b/CageUI/src/client/context/RoomContextManager.tsx index c0f23f3b6..c4675a0e1 100644 --- a/CageUI/src/client/context/RoomContextManager.tsx +++ b/CageUI/src/client/context/RoomContextManager.tsx @@ -30,7 +30,7 @@ import { Rack, RackConditionOption, Room, - RoomMods, SessionLog + RoomMods, RoomObject, SessionLog } from '../types/typings'; import { ModificationSaveResult, RackSwitchOption } from '../types/homeTypes'; import { LayoutSaveResult, RackChangeSaveResult } from '../types/layoutEditorTypes'; @@ -53,7 +53,7 @@ export const useRoomContext = () => { }; export const RoomContextProvider = ({children}) => { - const {selectedRoom, setSelectedRoom} = useHomeNavigationContext(); + const {selectedLocalRoom, setSelectedLocalRoom} = useHomeNavigationContext(); const [sessionLog, setSessionLog] = useState({ startTime: toLabKeyDate(new Date()), userAgent: navigator.userAgent, @@ -61,10 +61,22 @@ export const RoomContextProvider = ({children}) => { queryName: null, }); + const saveRoomObj = (itemId: string, newObj: RoomObject)=> { + setSelectedLocalRoom(prevState => ({ + ...prevState, + objects: prevState.objects.map(obj => { + if(obj.itemId === itemId){ + return newObj; + } + return obj; + }) + })); + } + const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { const cageModsByCage: { [key in string]: CageModificationsType } = {}; // string is object uuid let idsToRemove: Map = new Map(); - const newRoomMods: RoomMods = {...selectedRoom.mods}; + const newRoomMods: RoomMods = {...selectedLocalRoom.mods}; // Add adjacent cage mods @@ -159,7 +171,7 @@ export const RoomContextProvider = ({children}) => { } }); - setSelectedRoom( + setSelectedLocalRoom( prevState => ({ ...prevState, rackGroups: prevState.rackGroups.map((g) => ({ @@ -184,7 +196,7 @@ export const RoomContextProvider = ({children}) => { const submitLayoutMods = async (): Promise => { const newSessionLog: SessionLog = {...sessionLog, queryName: 'cage_modifications_history'}; - return saveRoomHelper(selectedRoom, newSessionLog); + return saveRoomHelper(selectedLocalRoom, newSessionLog); }; const submitRackChange = async (newRackOption: RackSwitchOption, prevRack: Rack, prevRackCondition: RackConditionOption): Promise => { @@ -194,12 +206,12 @@ export const RoomContextProvider = ({children}) => { let newRack: string; const newSessionLog: SessionLog = {...sessionLog, queryName: 'rack_history'}; try { - const newRoomRes = await createNewRoomFromRackChange(selectedRoom, newRackOption, prevRack); + const newRoomRes = await createNewRoomFromRackChange(selectedLocalRoom, newRackOption, prevRack); newRoom = newRoomRes.room; let errors; if (newRoomRes.errors) { errors = Array.isArray(newRoomRes.errors) ? newRoomRes.errors : [newRoomRes.errors]; - result = {success: false, roomName: selectedRoom.name, rack: "",reason: errors}; + result = {success: false, roomName: selectedLocalRoom.name, rack: "",reason: errors}; return result; } newRack = newRoomRes.rack; @@ -208,7 +220,7 @@ export const RoomContextProvider = ({children}) => { const errors = Array.isArray(e.errors) ? e.errors : [e.errors]; result = { success: e.success, - roomName: selectedRoom.name, + roomName: selectedLocalRoom.name, rack: "", reason: errors.map(err => err.message || err) }; @@ -225,7 +237,8 @@ export const RoomContextProvider = ({children}) => { {children} diff --git a/CageUI/src/client/types/homeNavigationContextTypes.ts b/CageUI/src/client/types/homeNavigationContextTypes.ts index a2c067177..7f6d1a87d 100644 --- a/CageUI/src/client/types/homeNavigationContextTypes.ts +++ b/CageUI/src/client/types/homeNavigationContextTypes.ts @@ -20,11 +20,13 @@ import { SelectedPage } from './homeTypes'; import { Cage, Rack, RackGroup, Room, RoomMods } from './typings'; import { SetStateAction } from 'react'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; +import * as React from 'react'; export interface HomeNavigationContextType { selectedPage: SelectedPage; selectedRoom: Room; - setSelectedRoom: React.Dispatch>; + selectedLocalRoom: Room; + setSelectedLocalRoom: React.Dispatch>; selectedRoomMods: RoomMods; selectedRackGroup: RackGroup; selectedRack: Rack; diff --git a/CageUI/src/client/types/roomContextTypes.ts b/CageUI/src/client/types/roomContextTypes.ts index 9e744d284..e2b5f6b92 100644 --- a/CageUI/src/client/types/roomContextTypes.ts +++ b/CageUI/src/client/types/roomContextTypes.ts @@ -16,12 +16,14 @@ * */ -import { Cage, CurrCageMods, Rack, RackConditionOption } from './typings'; +import { Cage, CurrCageMods, Rack, RackConditionOption, RoomObject } from './typings'; import { ModificationSaveResult, RackSwitchOption } from './homeTypes'; import { LayoutSaveResult, RackChangeSaveResult } from './layoutEditorTypes'; export interface RoomContextType { saveCageMods: (currCage: Cage, currCageMods: CurrCageMods) => ModificationSaveResult; submitLayoutMods: () => Promise; - submitRackChange: (newRack: RackSwitchOption, prevRack: Rack, prevRackCondition: RackConditionOption) => Promise + submitRackChange: (newRack: RackSwitchOption, prevRack: Rack, prevRackCondition: RackConditionOption) => Promise; + saveRoomObj: (itemId: string, newObj: RoomObject) => void; + } \ No newline at end of file diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 8faa49ac4..196b01b2c 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -405,7 +405,7 @@ export function setupEditCageEvent( eventType: "edit" | "view", setCtxMenuStyle?: React.Dispatch>, ): () => void { - + // Main context menu handler const handleContextMenu = (event: MouseEvent | CustomEvent) => { // Only block native menu if we're using a custom one diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index d6e9f7bc7..d51dad480 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -207,6 +207,29 @@ export const parseLongId = (input: string) => { return; }; +export const formatRoomObj = (input: string): string => { + // Handle the special cases with any digit after hyphen + if (input.startsWith("gateClosed-") || input.startsWith("gateOpen-")) { + return "Gate"; +} + // Remove the "-{digit}" suffix if present + let cleanString = input.replace(/-\d+$/, ''); + + // Handle empty string + if (!cleanString) return ''; + + // Split on uppercase letters and hyphens, then filter out empty strings + const parts = cleanString.split(/(?=[A-Z])|[-_]/).filter(part => part.length > 0); + + // Capitalize first letter of each part and make the rest lowercase + return parts + .map(part => { + if (part.length === 0) return ''; + return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); + }) + .join(' '); +} + export const formatCageNum = (str: string) => { // Split the string by hyphens try {// if the rack is default split and correctly display it @@ -597,10 +620,12 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | // We are loading an entire room into the svg if (renderType === 'room') { + // Render rack groups, racks, and cages (unitsToRender as Room).rackGroups.forEach((group) => { createGroup(group); }); + // Render room objects (unitsToRender as Room).objects.forEach(async (roomObj) => { const wrapperGroup = layoutSvg.append('g') .attr('id', roomObj.itemId + '-wrapper') @@ -618,9 +643,8 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | objSvg = (d3.select(`[id=${roomItemToString(roomObj.type)}_template_wrapper]`) as d3.Selection).node().cloneNode(true) as SVGElement; } else if (mode === 'view') { await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${roomItemToString(roomObj.type)}.svg`).then((d) => { - (roomObjGroup.node() as SVGElement).appendChild(d.documentElement); + objSvg = d.querySelector('svg'); }); - return; } const shape = d3.select(objSvg) @@ -634,9 +658,11 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | if(canOpenContextMenu(user, roomObj.type)){ setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); } - // Attach drag functionality if user has permissions - if(isDraggable(user, roomObj.type)){ - wrapperGroup.call(closeMenuThenDrag); + if(mode === 'edit'){ + // Attach drag functionality if user has permissions + if(isDraggable(user, roomObj.type)){ + wrapperGroup.call(closeMenuThenDrag); + } } }); } else if (renderType === 'group') { // we are rendering a single rack group From e3a70c9e3aac62f0b9a548f32f1a40e65d9d86be Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Mon, 13 Apr 2026 16:28:30 -0500 Subject: [PATCH 18/24] Update RoomLayout.tsx --- .../components/home/roomView/RoomLayout.tsx | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index b5b9e9b85..ae28bf53f 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -51,10 +51,6 @@ export const RoomLayout: FC = (props) => { const borderRef = useRef(null); const contextRef = useRef(selectedLocalRoom); - useEffect(() => { - console.log("Selected Room: ", selectedLocalRoom); - }, [selectedLocalRoom]); - // Loads room into the svg useEffect(() => { if (!selectedLocalRoom.name) { @@ -94,20 +90,16 @@ export const RoomLayout: FC = (props) => { setSelectedContextObj(null); }, [showCageContextMenu, showObjContextMenu]); + /* Mods equal here won't always work since keys are UUIDs and won't be the same. This is a small bug but only an + / issue for user experience (changing a mod then changing it back to the prev mod will still show save button). + / The solution to this would be to write a custom method to check the deep version of the prev room and local room. + / This would take some time and can be added later if requested/needed. + */ useEffect(() => { - if (!selectedLocalRoom.mods || !selectedRoomMods) { - return; - } - setShowChangesMenu(!(_.isEqual(selectedRoomMods, selectedLocalRoom.mods))); - }, [selectedLocalRoom.mods]); - - useEffect(() => { - if (!selectedRoom || selectedLocalRoom.objects.length === 0) { - return; - } - setShowChangesMenu(!(_.isEqual(selectedRoom.objects, selectedLocalRoom.objects))); - }, [selectedLocalRoom.objects]); - + const modsEqual = _.isEqual(selectedRoomMods, selectedLocalRoom.mods); + const objectsEqual = _.isEqual(selectedRoom.objects, selectedLocalRoom.objects); + setShowChangesMenu(!modsEqual || !objectsEqual); + }, [selectedRoom, selectedLocalRoom, selectedRoomMods]); const saveLayout = async () => { From 7d8e3edd7f74bdef6c9e229765f1f47dfca42c39 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Tue, 14 Apr 2026 12:13:28 -0500 Subject: [PATCH 19/24] Fix bug with the svg loading not working for cage popups --- .../home/cageView/CurrentCageLayout.tsx | 7 +- .../components/home/roomView/CagePopup.tsx | 17 +- .../home/roomView/ModificationEditor.tsx | 19 +- .../src/client/context/RoomContextManager.tsx | 42 +++- CageUI/src/client/utils/homeHelpers.ts | 199 +++++++++++++++++- 5 files changed, 272 insertions(+), 12 deletions(-) diff --git a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx index 74cdfbf0f..15d135ede 100644 --- a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx +++ b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx @@ -21,16 +21,17 @@ import { FC, useEffect, useRef } from 'react'; import '../../../cageui.scss'; import { addPrevRoomSvgs } from '../../../utils/helpers'; import * as d3 from 'd3'; -import { Cage } from '../../../types/typings'; +import { Cage, RoomMods } from '../../../types/typings'; import { CELL_SIZE } from '../../../utils/constants'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; interface CurrentCageLayoutProps { cage: Cage; + cageRoomMods: RoomMods; } export const CurrentCageLayout: FC = (props) => { - const {cage} = props; + const {cage, cageRoomMods} = props; const {selectedLocalRoom, userProfile} = useHomeNavigationContext(); const cageRef = useRef(null); @@ -45,7 +46,7 @@ export const CurrentCageLayout: FC = (props) => { const element = d3.select(this) as d3.Selection; element.remove(); }); - addPrevRoomSvgs(userProfile, 'view', cage, cageSvg, selectedLocalRoom, selectedLocalRoom.mods); + addPrevRoomSvgs(userProfile, 'view', cage, cageSvg, selectedLocalRoom, cageRoomMods); }, [cage]); // adding 1 to the width/height helps make sure the lines don't get cut off in the image diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index d8d6c67ef..8e53c8a19 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -42,6 +42,7 @@ export const CagePopup: FC = (props) => { const {saveCageMods} = useRoomContext(); const {selectedLocalRoom, userProfile} = useHomeNavigationContext(); + const [prevCage, setPrevCage] = useState(null); const [currCage, setCurrCage] = useState(null); const [currRack, setCurrRack] = useState(null); const [currCageMods, setCurrCageMods] = useState(null); @@ -53,11 +54,23 @@ export const CagePopup: FC = (props) => { const tempCage = selectedObj as Cage; if (tempCage) { const cageRack = findCageInGroup(tempCage.svgId, selectedLocalRoom.rackGroups).rack; - setCurrCage(tempCage); + setPrevCage(tempCage); setCurrRack(cageRack); } }, [selectedObj]); + useEffect(() => { + setCurrCage(prevCage); + }, [prevCage]); + + /*useEffect(() => { + if(currCage && currCageMods){ + const newMods = buildUpdatedCageAndRoomMods(selectedLocalRoom, currCage, currCageMods); + console.log("newMods: ", newMods); + setCurrCage({...currCage, mods: newMods.cageModsByCage[currCage.objectId]}); + } + }, [currCageMods]);*/ + useEffect(() => { // Check if the click was outside the menu @@ -94,7 +107,7 @@ export const CagePopup: FC = (props) => { // This submission updates the room mods with the current selections. const handleSaveMods = () => { - const result = saveCageMods(currCage, currCageMods); + const result = saveCageMods(prevCage, currCageMods); if (result) { if (result.status === 'Success') { diff --git a/CageUI/src/client/components/home/roomView/ModificationEditor.tsx b/CageUI/src/client/components/home/roomView/ModificationEditor.tsx index 3fd00ee58..826ae957a 100644 --- a/CageUI/src/client/components/home/roomView/ModificationEditor.tsx +++ b/CageUI/src/client/components/home/roomView/ModificationEditor.tsx @@ -19,9 +19,11 @@ import * as React from 'react'; import { FC, useEffect, useState } from 'react'; import '../../../cageui.scss'; -import { Cage, CurrCageMods, ModLocations, Rack } from '../../../types/typings'; +import { Cage, CurrCageMods, ModLocations, Rack, RoomMods } from '../../../types/typings'; import { CurrentCageLayout } from '../cageView/CurrentCageLayout'; import { CageModifications } from './CageModifications'; +import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { buildUpdatedCageAndRoomMods } from '../../../utils/homeHelpers'; interface ModificationEditorProps { currCage: Cage; @@ -39,6 +41,10 @@ export const ModificationEditor: FC = (props) => { currRack, updateCageMods, } = props; + const {selectedLocalRoom} = useHomeNavigationContext(); + + const [localCage, setLocalCage] = useState(currCage); + const [cageRoomMods, setCageRoomMods] = useState(selectedLocalRoom.mods); const [currCageMods, setCurrCageMods] = useState({ adjCages: { @@ -53,6 +59,14 @@ export const ModificationEditor: FC = (props) => { useEffect(() => { if(currCageMods){ updateCageMods(currCageMods); + + const {cageModsByCage, newRoomMods} = buildUpdatedCageAndRoomMods(selectedLocalRoom, currCage, currCageMods); + + setLocalCage((prevState) => ({ + ...prevState, + mods: cageModsByCage[prevState.objectId] + })); + setCageRoomMods(newRoomMods); } }, [currCageMods]); @@ -67,7 +81,8 @@ export const ModificationEditor: FC = (props) => { setCurrCageMods={setCurrCageMods} />
diff --git a/CageUI/src/client/context/RoomContextManager.tsx b/CageUI/src/client/context/RoomContextManager.tsx index c4675a0e1..9ec43d0a6 100644 --- a/CageUI/src/client/context/RoomContextManager.tsx +++ b/CageUI/src/client/context/RoomContextManager.tsx @@ -36,6 +36,7 @@ import { ModificationSaveResult, RackSwitchOption } from '../types/homeTypes'; import { LayoutSaveResult, RackChangeSaveResult } from '../types/layoutEditorTypes'; import { useHomeNavigationContext } from './HomeNavigationContextManager'; import { createNewRoomFromRackChange } from '../api/labkeyActions'; +import { buildUpdatedCageAndRoomMods } from '../utils/homeHelpers'; const RoomContext = createContext({} as RoomContextType); @@ -73,7 +74,9 @@ export const RoomContextProvider = ({children}) => { })); } - const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { + + + /*const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { const cageModsByCage: { [key in string]: CageModificationsType } = {}; // string is object uuid let idsToRemove: Map = new Map(); const newRoomMods: RoomMods = {...selectedLocalRoom.mods}; @@ -192,8 +195,45 @@ export const RoomContextProvider = ({children}) => { ); return {status: 'Success'}; + };*/ + + const saveCageMods = ( + currCage: Cage, + currCageMods: CurrCageMods + ): ModificationSaveResult => { + // Phase 1: Build updated structures (pure) + const { cageModsByCage, newRoomMods } = + buildUpdatedCageAndRoomMods(selectedLocalRoom, currCage, currCageMods); + + + + // Phase 2: Update React state + setSelectedLocalRoom(prevState => { + // Deep-update cages only where mods were changed + const updatedRackGroups = prevState.rackGroups.map(rg => ({ + ...rg, + racks: rg.racks.map(rack => ({ + ...rack, + cages: rack.cages.map(cage => { + if (cageModsByCage[cage.objectId]) { + return { ...cage, mods: cageModsByCage[cage.objectId] }; + } + return cage; + }), + })), + })); + + return { + ...prevState, + rackGroups: updatedRackGroups, + mods: newRoomMods, + }; + }); + + return { status: 'Success' }; }; + const submitLayoutMods = async (): Promise => { const newSessionLog: SessionLog = {...sessionLog, queryName: 'cage_modifications_history'}; return saveRoomHelper(selectedLocalRoom, newSessionLog); diff --git a/CageUI/src/client/utils/homeHelpers.ts b/CageUI/src/client/utils/homeHelpers.ts index 33c48fac4..5f9c35f2c 100644 --- a/CageUI/src/client/utils/homeHelpers.ts +++ b/CageUI/src/client/utils/homeHelpers.ts @@ -18,16 +18,24 @@ import { Cage, - CageDirection, + CageDirection, CageModification, CageModificationsType, CageNumber, CurrCageMods, ModDirections, ModLocations, - ModTypes, + ModTypes, Room, RoomMods } from '../types/typings'; -import { isRoomCreator, isRoomModifier, isTemplateCreator, parseRoomItemNum, parseRoomItemType } from './helpers'; +import { + getAdjLocation, + isRoomCreator, + isRoomModifier, + isTemplateCreator, + parseRoomItemNum, + parseRoomItemType +} from './helpers'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; +import { ConnectedModType } from '../types/homeTypes'; // Determines if the user has access to editing the layout @@ -120,4 +128,187 @@ export const findDetails = (clickedCage, cageDetails, rack) => { } } }); -}; \ No newline at end of file +}; + + +interface BuildResult { + cageModsByCage: { [key: string]: CageModificationsType }; + newRoomMods: RoomMods; +} + +/** + * Builds updated cage modifications and room mods based on current changes, + * without modifying React state. + */ +export const buildUpdatedCageAndRoomMods = ( + selectedLocalRoom: Room, + currCage: Cage, + currCageMods: CurrCageMods +): BuildResult => { + const cageModsByCage: { [key: string]: CageModificationsType } = {}; + const idsToRemove = new Set(); + const newRoomMods: RoomMods = { ...selectedLocalRoom.mods }; // shallow copy of current room mods + + // --- 1. Process adjacent cages --- + Object.entries(currCageMods.adjCages).forEach(([dirKey, allDirMods]) => { + allDirMods.forEach((modSubsection) => { + const { currMods = [], adjMods = [], currCage: adjCurrCage, adjCage: adjAdjCage } = modSubsection; + + const currCageId = adjCurrCage.objectId; + const adjCageId = adjAdjCage.objectId; + + // Initialize cage modifications if missing (deep copy the existing mods) + if (!cageModsByCage[currCageId]) { + cageModsByCage[currCageId] = deepCopyCageMods(adjCurrCage.mods); + } + if (!cageModsByCage[adjCageId]) { + cageModsByCage[adjCageId] = deepCopyCageMods(adjAdjCage.mods); + } + + // Step A: Add new mods to room-wide mods registry + [...currMods, ...adjMods].forEach(mod => { + newRoomMods[mod.modId] = { label: mod.label, value: mod.value }; + }); + + // Step B: Collect mod IDs to remove (from old modKeys in same dir/subId) + const oldModIds = [ + // From current cage's mods in this direction + subId + ...(cageModsByCage[currCageId][dirKey] ?? []) + .filter(cm => cm.subId === modSubsection.currSubId) + .flatMap(cm => cm.modKeys.map(m => m.modId)), + // From adjacent cage's mods in *reverse* direction + same subId + ...(cageModsByCage[adjCageId][getAdjLocation(parseInt(dirKey)) as ModLocations] ?? []) + .filter(cm => cm.subId === modSubsection.adjSubId) + .flatMap(cm => cm.modKeys.map(m => m.modId)), + ]; + + oldModIds.forEach(id => idsToRemove.add(id)); + + // Step C: Update modKeys for current cage + cageModsByCage[currCageId][dirKey] = ( + cageModsByCage[currCageId][dirKey] || [] + ).map((cm: CageModification) => { + if (cm.subId === modSubsection.currSubId) { + const updatedModKeys = currMods.map(m => ({ + modId: m.modId, + parentModId: m.parentModId ?? null, + })); + + // De-duplicate removals: if new mod has same ID as an old one we're removing, don't remove it + updatedModKeys.forEach(m => idsToRemove.delete(m.modId)); + + return { + ...cm, + modKeys: updatedModKeys, + }; + } + return cm; + }); + + // Step D: Update modKeys for adjacent cage + const reverseDir = getAdjLocation(parseInt(dirKey)) as ModLocations; + cageModsByCage[adjCageId][reverseDir] = ( + cageModsByCage[adjCageId][reverseDir] || [] + ).map((cm: CageModification) => { + if (cm.subId === modSubsection.adjSubId) { + const updatedModKeys = adjMods.map(m => ({ + modId: m.modId, + parentModId: m.parentModId ?? null, + })); + + updatedModKeys.forEach(m => idsToRemove.delete(m.modId)); + + return { + ...cm, + modKeys: updatedModKeys, + }; + } + return cm; + }); + }); + }); + + // --- 2. Process current (direct) cage mods --- + const directKey = ModLocations.Direct; + + // Remove old direct mod keys + const currDirectMods = currCage.mods?.[directKey] ?? []; + if (currDirectMods.length > 0) { + currDirectMods[0].modKeys.forEach(m => idsToRemove.add(m.modId)); + } + + // Add new direct mods + const newDirectMods = currCageMods.currCage.map(mod => { + newRoomMods[mod.modId] = { label: mod.label, value: mod.value }; + idsToRemove.delete(mod.modId); // prevent removal if re-saved unchanged + return { + modId: mod.modId, + parentModId: mod.parentModId ?? null, + }; + }); + + // Update direct cage mods (only first subId = 1 is used) + const currCageId = currCage.objectId; + if (!cageModsByCage[currCageId]) { + cageModsByCage[currCageId] = deepCopyCageMods(currCage.mods); + } + cageModsByCage[currCageId][directKey] = newDirectMods.length + ? [{ subId: 1, modKeys: newDirectMods }] + : []; + + // Apply removals (already tracked in Set → delete from newRoomMods) + idsToRemove.forEach(modId => { + delete newRoomMods[modId]; + }); + + return { cageModsByCage, newRoomMods }; +}; + +/** + * Helper to deep-clone cage mods safely (avoids mutating original) + */ +const deepCopyCageMods = (mods?: CageModificationsType): CageModificationsType => { + if (!mods) return initialCageMods(); // assuming you have a fallback + return Object.fromEntries( + Object.entries(mods).map(([dir, cMods]) => [ + dir, + cMods.map(cm => ({ + ...cm, + modKeys: [...cm.modKeys], + })), + ]) + ) as CageModificationsType; +}; + +// You’ll need this fallback somewhere — e.g., for empty cages +const initialCageMods = (): CageModificationsType => ({ + [ModLocations.Top]: [], + [ModLocations.Bottom]: [], + [ModLocations.Left]: [], + [ModLocations.Right]: [], + [ModLocations.Direct]: [], +}); + + +/** + * Updates only the `currCage`'s mods in isolation — + * doesn’t touch adjacents or room mods (for unit testing or isolated edits). + */ +export const updateCurrCageModsOnly = ( + cage: Cage, + currCageMods: ConnectedModType[] +): CageModificationsType => { + const mods = deepCopyCageMods(cage.mods); + + const directKey = ModLocations.Direct; + const newDirectKeys = currCageMods.map(m => ({ + modId: m.modId, + parentModId: m.parentModId ?? null, + })); + + mods[directKey] = newDirectKeys.length + ? [{ subId: 1, modKeys: newDirectKeys }] + : []; + + return mods; +}; From 0d3ec2c1ad2c019aaeaa3065c5d2eb96883ca190 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Tue, 14 Apr 2026 12:13:38 -0500 Subject: [PATCH 20/24] Add new mods to java types --- CageUI/src/org/labkey/cageui/model/ModTypes.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CageUI/src/org/labkey/cageui/model/ModTypes.java b/CageUI/src/org/labkey/cageui/model/ModTypes.java index 280751f14..6497ec2ef 100644 --- a/CageUI/src/org/labkey/cageui/model/ModTypes.java +++ b/CageUI/src/org/labkey/cageui/model/ModTypes.java @@ -34,7 +34,9 @@ public enum ModTypes NoDivider("nd"), CTunnel("ct"), Extension("ex"), - SPDivider("spd"); + SPDivider("spd"), + Restraint("res"), + Blind("bld"); private final String value; From 35718df29171f36c8ccee3ec358e8f207b4fb115 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Tue, 14 Apr 2026 15:48:02 -0500 Subject: [PATCH 21/24] Bug fix for save a cage with no separator style mods causing issues If a user submitted a mod after saving the table with no mod between cages this would break the mods from loading. This change ensures that default mods are added after the user saves the current mods for a cage, if no separator mods exist in that section. --- .../home/roomView/CageModifications.tsx | 15 ++-- .../components/home/roomView/CagePopup.tsx | 72 ++++++++++++++++--- .../home/roomView/ModificationMultiSelect.tsx | 24 +++---- .../components/home/roomView/RoomLayout.tsx | 2 +- CageUI/src/client/types/homeTypes.ts | 2 +- CageUI/src/client/types/typings.ts | 4 +- CageUI/src/client/utils/helpers.ts | 13 ++-- CageUI/src/client/utils/homeHelpers.ts | 4 +- 8 files changed, 93 insertions(+), 43 deletions(-) diff --git a/CageUI/src/client/components/home/roomView/CageModifications.tsx b/CageUI/src/client/components/home/roomView/CageModifications.tsx index 5dd98032d..9d0fbbc2a 100644 --- a/CageUI/src/client/components/home/roomView/CageModifications.tsx +++ b/CageUI/src/client/components/home/roomView/CageModifications.tsx @@ -64,8 +64,7 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.currMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedLocalRoom.mods[key.modId].label, - value: selectedLocalRoom.mods[key.modId].value, + ...selectedLocalRoom.mods[key.modId], modId: key.modId, parentModId: key.parentModId }; @@ -81,8 +80,7 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.adjMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedLocalRoom.mods[key.modId].label, - value: selectedLocalRoom.mods[key.modId].value, + ...selectedLocalRoom.mods[key.modId], modId: key.modId, parentModId: key.parentModId }; @@ -114,8 +112,7 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.currMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedLocalRoom.mods[key.modId].label, - value: selectedLocalRoom.mods[key.modId].value, + ...selectedLocalRoom.mods[key.modId], modId: key.modId, parentModId: key.parentModId }; @@ -131,8 +128,7 @@ export const CageModifications: FC = (props) => { if (modKeysInLoc.subId === connection.currSubId) { connection.adjMods = modKeysInLoc.modKeys.map((key: ModKeyMap) => { return { - label: selectedLocalRoom.mods[key.modId].label, - value: selectedLocalRoom.mods[key.modId].value, + ...selectedLocalRoom.mods[key.modId], modId: key.modId, parentModId: key.parentModId }; @@ -256,8 +252,7 @@ export const CageModifications: FC = (props) => { handleChange={(selectedItems) => handleChange(ModLocations.Direct, cage, selectedItems)} prevItems={cage.mods[ModLocations.Direct].flatMap(subMods => { return subMods.modKeys.map(key => ({ - label: selectedLocalRoom.mods[key.modId].label, - value: selectedLocalRoom.mods[key.modId].value, + ...selectedLocalRoom.mods[key.modId], modId: key.modId })); })} diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index 8e53c8a19..c4ab475f3 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -21,13 +21,15 @@ import { FC, useEffect, useRef, useState } from 'react'; import '../../../cageui.scss'; import { ModificationEditor } from './ModificationEditor'; import { SelectedObj } from '../../../types/layoutEditorTypes'; -import { Cage, CurrCageMods, Rack } from '../../../types/typings'; +import { Cage, CurrCageMods, ModDirections, ModLocations, ModStyle, ModTypes, Rack } from '../../../types/typings'; import { findCageInGroup } from '../../../utils/LayoutEditorHelpers'; import { useRoomContext } from '../../../context/RoomContextManager'; import { Button } from 'react-bootstrap'; import { AnimalEditor } from './AnimalEditor'; -import { formatCageNum, isCageModifier } from '../../../utils/helpers'; +import { formatCageNum, generateUUID, isCageModifier } from '../../../utils/helpers'; import { useHomeNavigationContext } from '../../../context/HomeNavigationContextManager'; +import { ConnectedCage, ConnectedRack } from '../../../types/homeTypes'; +import { cageModLookup } from '../../../api/popularQueries'; interface CagePopupProps { selectedObj: SelectedObj; @@ -107,15 +109,67 @@ export const CagePopup: FC = (props) => { // This submission updates the room mods with the current selections. const handleSaveMods = () => { - const result = saveCageMods(prevCage, currCageMods); - - if (result) { - if (result.status === 'Success') { - handleCleanup(); - } else { - setShowError(result.reason.map((err, index) => `${index + 1}. ${err}`).join('\n')); + console.log("SaveMods: ", currCageMods); + validateAndApplyDefaults(currCageMods).then((res) => { + const result = saveCageMods(prevCage, res); + + if (result) { + if (result.status === 'Success') { + handleCleanup(); + } else { + setShowError(result.reason.map((err, index) => `${index + 1}. ${err}`).join('\n')); + } } + }); + }; + + // Function ensures that default mods are chosen if the user fails to pick any mods and the selection component is empty when saving. + const validateAndApplyDefaults = async (mods: CurrCageMods): Promise => { + const cageModData = await cageModLookup([],[]); + const fillDefaultMods = (direction: ModDirections, connections: ConnectedRack[] | ConnectedCage[]) => { + // Define your default values here + const defaultHorizontalMod = cageModData.find((mod) => mod.value === ModTypes.SolidDivider); + const defaultVerticalMod = cageModData.find((mod) => mod.value === ModTypes.StandardFloor); + const defaultModValue = direction === ModDirections.Vertical ? defaultVerticalMod : defaultHorizontalMod; + + + const newConnections = connections.map((connection: ConnectedRack | ConnectedCage) => { + const containsAdjDivider = connection.adjMods.find(mod => mod.type === ModStyle.Separator); + const containsCurrDivider = connection.currMods.find(mod => mod.type === ModStyle.Separator); + if(!(containsAdjDivider || containsCurrDivider)){ + const modId = generateUUID(); + return { + ...connection, + adjMods: [...connection.adjMods, { + ...defaultModValue, + modId: generateUUID(), + parentModId: modId + }], + currMods: [...connection.currMods, { + ...defaultModValue, + modId: modId, + }] + } + }else{ + return connection; + } + }); + return newConnections; } + + // Apply defaults to empty directions + let modifiedMods = { + ...mods, + adjCages: { + ...mods.adjCages, + [ModLocations.Left]: fillDefaultMods(ModDirections.Horizontal, mods.adjCages[ModLocations.Left]), + [ModLocations.Right]: fillDefaultMods(ModDirections.Horizontal, mods.adjCages[ModLocations.Right]), + [ModLocations.Top]: fillDefaultMods(ModDirections.Vertical, mods.adjCages[ModLocations.Top]), + [ModLocations.Bottom]: fillDefaultMods(ModDirections.Vertical, mods.adjCages[ModLocations.Bottom]), + }, + }; + + return modifiedMods; }; return ( diff --git a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx index 132ad4ebe..56209819c 100644 --- a/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx +++ b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx @@ -39,7 +39,7 @@ export const ModificationMultiSelect: FC = (props) const [searchTerm, setSearchTerm] = useState(''); const dropdownRef = useRef(null); - const [options, setOptions] = useState[]>(null); + const [options, setOptions] = useState[]>(null); const [availableMods, setAvailableMods] = useState(null); useEffect(() => { @@ -49,7 +49,7 @@ export const ModificationMultiSelect: FC = (props) // If nothing is selected, reset to all available options if (!selectedItems || selectedItems.length === 0) { - setOptions(availableMods.map(m => ({label: m.title, value: m.value}))); + setOptions(availableMods.map(m => ({label: m.title, value: m}))); return; } @@ -74,7 +74,7 @@ export const ModificationMultiSelect: FC = (props) return !selectedDirTypePairs.has(key); }); - setOptions(allowedMods.map(m => ({label: m.title, value: m.value}))); + setOptions(allowedMods.map(m => ({label: m.title, value: m}))); }, [selectedItems, availableMods]); useEffect(() => { @@ -85,10 +85,10 @@ export const ModificationMultiSelect: FC = (props) cageModLookup([], [directionFilter]).then(result => { if (result.length !== 0) { - const rowOptions: Option[] = []; + const rowOptions: Option[] = []; const availMods: EHRCageMods[] = []; result.forEach(row => { - rowOptions.push({label: row.title, value: row.value as ModTypes}); + rowOptions.push({label: row.title, value: row}); availMods.push({...row}); }); setAvailableMods(availMods); @@ -127,12 +127,12 @@ export const ModificationMultiSelect: FC = (props) } } - const handleSelectItem = (item: Option) => { + const handleSelectItem = (item: Option) => { const newItems = selectedItems || []; - if (!newItems.find(items => items.value === item.value)) { + if (!newItems.find(items => items.value === item.value.value)) { setSelectedItems([...newItems, { - ...item, + ...item.value, modId: generateUUID(), }]); } @@ -140,13 +140,13 @@ export const ModificationMultiSelect: FC = (props) setIsOpen(false); }; - const removeItem = (itemToRemove) => { - setSelectedItems(selectedItems.filter(item => item !== itemToRemove)); + const removeItem = (itemToRemove: ConnectedModType) => { + setSelectedItems(selectedItems.filter(item => item.value !== itemToRemove.value)); }; const filteredOptions = options?.filter(option => option.label.toLowerCase().includes(searchTerm.toLowerCase()) && - !selectedItems.find(item => item.value === option.value) + !selectedItems.find(item => item.value === option.value.value) ); return ( @@ -157,7 +157,7 @@ export const ModificationMultiSelect: FC = (props) ) : ( selectedItems.map(item => (
- {item.label} + {item.title} { diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index ae28bf53f..a5931267d 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -97,7 +97,7 @@ export const RoomLayout: FC = (props) => { */ useEffect(() => { const modsEqual = _.isEqual(selectedRoomMods, selectedLocalRoom.mods); - const objectsEqual = _.isEqual(selectedRoom.objects, selectedLocalRoom.objects); + const objectsEqual = _.isEqual(selectedRoom?.objects, selectedLocalRoom.objects); setShowChangesMenu(!modsEqual || !objectsEqual); }, [selectedRoom, selectedLocalRoom, selectedRoomMods]); diff --git a/CageUI/src/client/types/homeTypes.ts b/CageUI/src/client/types/homeTypes.ts index 4b35b0db2..87be7b577 100644 --- a/CageUI/src/client/types/homeTypes.ts +++ b/CageUI/src/client/types/homeTypes.ts @@ -33,7 +33,7 @@ import { Option } from '@labkey/components'; export type SelectedViews = 'Home' | 'Room' | 'Rack' | 'Cage'; -export type ConnectedModType = Partial> & { modId: ModIdKey, parentModId?: ModIdKey }; +export type ConnectedModType = Partial & { modId: ModIdKey, parentModId?: ModIdKey }; export type ExpandedRooms = { [key: string]: boolean; diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index 520b48010..cd307d423 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -17,7 +17,7 @@ */ import { GateContext } from './layoutEditorTypes'; -import { ConnectedCages, ConnectedModType, ConnectedRacks } from './homeTypes'; +import { ConnectedCages, ConnectedModType, ConnectedRacks, EHRCageMods } from './homeTypes'; import { Option } from '@labkey/components'; import { SelectorOptions } from '../components/layoutEditor/RoomSizeSelector'; @@ -213,7 +213,7 @@ export interface CageDimensions { } export interface RoomMods { - [key: ModIdKey]: Option; + [key: ModIdKey]: EHRCageMods; } export interface CurrCageMods { diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index d51dad480..373227e12 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -51,10 +51,10 @@ import { RoomObject, RoomObjectStringType, RoomObjectTypes, + SessionLog, TemplateHistoryData, UnitLocations, - UnitType, - SessionLog + UnitType } from '../types/typings'; import * as d3 from 'd3'; import { zoomTransform } from 'd3'; @@ -62,9 +62,11 @@ import { MutableRefObject } from 'react'; import { ActionURL, Filter, Security, Utils } from '@labkey/api'; import { addModEntries, - areAllRacksNonDefault, canOpenContextMenu, + areAllRacksNonDefault, + canOpenContextMenu, createEmptyUnitLoc, - findCageInGroup, isDraggable, + findCageInGroup, + isDraggable, isRackEnum, isRoomHomogeneousDefault, placeAndScaleGroup, @@ -839,8 +841,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit [ModLocations.Direct]: [] }; - const modReturnData = await cageModLookup([], []); - const availMods = modReturnData.map(row => ({value: row.value, label: row.title})); + const availMods = await cageModLookup([], []); const prevMods = prevRoom.modData.filter((mod) => mod.cage === cageData.objectId); prevMods.forEach((mod) => { diff --git a/CageUI/src/client/utils/homeHelpers.ts b/CageUI/src/client/utils/homeHelpers.ts index 5f9c35f2c..a6aff2061 100644 --- a/CageUI/src/client/utils/homeHelpers.ts +++ b/CageUI/src/client/utils/homeHelpers.ts @@ -167,7 +167,7 @@ export const buildUpdatedCageAndRoomMods = ( // Step A: Add new mods to room-wide mods registry [...currMods, ...adjMods].forEach(mod => { - newRoomMods[mod.modId] = { label: mod.label, value: mod.value }; + newRoomMods[mod.modId] = {direction: mod.direction, rowid: mod.rowid, title: mod.title, type: mod.type, value: mod.value}; }); // Step B: Collect mod IDs to remove (from old modKeys in same dir/subId) @@ -239,7 +239,7 @@ export const buildUpdatedCageAndRoomMods = ( // Add new direct mods const newDirectMods = currCageMods.currCage.map(mod => { - newRoomMods[mod.modId] = { label: mod.label, value: mod.value }; + newRoomMods[mod.modId] = {direction: mod.direction, rowid: mod.rowid, title: mod.title, type: mod.type, value: mod.value}; idsToRemove.delete(mod.modId); // prevent removal if re-saved unchanged return { modId: mod.modId, From 07c929e0ae4178ef0ea6527c4935923ed6f0fba9 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Wed, 15 Apr 2026 14:33:55 -0500 Subject: [PATCH 22/24] Remove room objects depending on permissions instead of blocking them with an error. --- .../client/components/layoutEditor/Editor.tsx | 156 +++++++++--------- .../src/client/utils/LayoutEditorHelpers.ts | 9 + CageUI/src/client/utils/helpers.ts | 25 +-- CageUI/src/client/utils/homeHelpers.ts | 28 +--- 4 files changed, 98 insertions(+), 120 deletions(-) diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index c997b2da4..b83ce70f0 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -32,6 +32,7 @@ import { RackChangeValue, RackGroup, RackStringType, + RackTypes, RoomItemType, RoomObject, RoomObjectTypes, @@ -46,7 +47,9 @@ import { } from '../../types/layoutEditorTypes'; import { LayoutTooltip } from './LayoutTooltip'; import { - areCagesInSameRack, canOpenContextMenu, canPlaceObject, + areCagesInSameRack, + canOpenContextMenu, + canPlaceObject, checkAdjacent, createDragInLayout, createEmptyUnitLoc, @@ -57,7 +60,8 @@ import { findCageInGroup, findRackInGroup, getLayoutOffset, - getTargetRect, isDraggable, + getTargetRect, + isDraggable, isRackEnum, mergeRacks, parseWrapperId, @@ -70,11 +74,11 @@ import { import { addPrevRoomSvgs, getNextDefaultRackId, + isRoomCreator, + isTemplateCreator, parseRoomItemNum, parseRoomItemType, roomItemToString, - isRoomCreator, - isTemplateCreator, stringToRoomItem } from '../../utils/helpers'; import { SelectorOptions } from './RoomSizeSelector'; @@ -117,7 +121,6 @@ const Editor: FC = ({roomSize}) => { const [templateOptions, setTemplateOptions] = useState(false); const [templateRename, setTemplateRename] = useState(null); const [startSaving, setStartSaving] = useState(false); - const [showPermissionError, setShowPermissionError] = useState(false); // number of cells in grid width/height, based off scale const gridWidth = Math.ceil(SVG_WIDTH / roomSize.scale / CELL_SIZE); @@ -265,16 +268,6 @@ const Editor: FC = ({roomSize}) => { shape = event.sourceEvent.target.cloneNode(true) as SVGElement; } - const draggedNodeId = d3.select(shape).attr('id'); - - const updateItemType: RoomItemType = stringToRoomItem(parseWrapperId(draggedNodeId)); - - if(!canPlaceObject(user, updateItemType)){ - // set error msg denying access to place this object - setShowPermissionError(true); - return; - } - d3.select(shape) .style('pointer-events', 'none') .attr('class', 'dragging'); @@ -886,62 +879,81 @@ const Editor: FC = ({roomSize}) => { }
- - - - - - - - - - - - - - - - - - - - - + {canPlaceObject(user, RoomObjectTypes.Top) && + + + + } + {canPlaceObject(user, RoomObjectTypes.Bottom) && + + + + } + {canPlaceObject(user, RoomObjectTypes.Door) && + + + + } + {canPlaceObject(user, RoomObjectTypes.Drain) && + + + + } + {canPlaceObject(user, RoomObjectTypes.RoomDivider) && + + + + } + {canPlaceObject(user, RoomObjectTypes.GateClosed) && + + + + } + {canPlaceObject(user, RoomObjectTypes.GateOpen) && + + + + } +
- - - - - - + {canPlaceObject(user, RackTypes.Cage) && + + + + } + {canPlaceObject(user, RackTypes.Pen) && + + + + }
@@ -1023,12 +1035,6 @@ const Editor: FC = ({roomSize}) => { >{localRoom.name === 'new-layout' ? 'Save Layout' : 'Update Layout'}
- {showPermissionError && - setShowPermissionError(null)} - /> - } {showSaveConfirm && ${localRoom.name} ?`} diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 196b01b2c..d339357a0 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -89,6 +89,9 @@ export const isDraggable = (user: GetUserPermissionsResponse, itemType: RoomItem if (RoomObjectTypes.RoomDivider === itemType){ return true; } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === itemType){ + return true; + } } return false; } @@ -102,6 +105,9 @@ export const canOpenContextMenu = (user: GetUserPermissionsResponse, itemType: R if (RoomObjectTypes.RoomDivider === itemType){ return true; } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === itemType){ + return true; + } } return false; } @@ -114,6 +120,9 @@ export const canPlaceObject = (user: GetUserPermissionsResponse, itemType: RoomI if (RoomObjectTypes.RoomDivider === itemType){ return true; } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === itemType){ + return true; + } } return false; } diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 373227e12..58555b31f 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -560,17 +560,9 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('transform', `translate(${cage.x},${cage.y})`); let unitSvg: SVGElement; - // If we are editing we can simply copy the svg from the ones displayed. - // If we are in view mode they aren't on the page so we must fetch and load them in - if (mode === 'edit') { - unitSvg = (d3.select(`[id=${rackTypeString}_template_wrapper]`) as d3.Selection) - .node().cloneNode(true) as SVGElement; - } else if (mode === 'view') { - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${rackTypeString}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - }); - } - + await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${rackTypeString}.svg`).then((d) => { + unitSvg = d.querySelector(`svg[id*=template]`); + }); // Only needed for layout editor to attach context menus const shape = d3.select(unitSvg); @@ -640,14 +632,9 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('transform', `translate(0,0)`) let objSvg: SVGElement; - - if (mode === 'edit') { - objSvg = (d3.select(`[id=${roomItemToString(roomObj.type)}_template_wrapper]`) as d3.Selection).node().cloneNode(true) as SVGElement; - } else if (mode === 'view') { - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${roomItemToString(roomObj.type)}.svg`).then((d) => { - objSvg = d.querySelector('svg'); - }); - } + await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${roomItemToString(roomObj.type)}.svg`).then((d) => { + objSvg = d.querySelector('svg'); + }); const shape = d3.select(objSvg) .classed('draggable', false) diff --git a/CageUI/src/client/utils/homeHelpers.ts b/CageUI/src/client/utils/homeHelpers.ts index a6aff2061..573abfeab 100644 --- a/CageUI/src/client/utils/homeHelpers.ts +++ b/CageUI/src/client/utils/homeHelpers.ts @@ -136,7 +136,7 @@ interface BuildResult { newRoomMods: RoomMods; } -/** +/* * Builds updated cage modifications and room mods based on current changes, * without modifying React state. */ @@ -264,7 +264,7 @@ export const buildUpdatedCageAndRoomMods = ( return { cageModsByCage, newRoomMods }; }; -/** +/* * Helper to deep-clone cage mods safely (avoids mutating original) */ const deepCopyCageMods = (mods?: CageModificationsType): CageModificationsType => { @@ -288,27 +288,3 @@ const initialCageMods = (): CageModificationsType => ({ [ModLocations.Right]: [], [ModLocations.Direct]: [], }); - - -/** - * Updates only the `currCage`'s mods in isolation — - * doesn’t touch adjacents or room mods (for unit testing or isolated edits). - */ -export const updateCurrCageModsOnly = ( - cage: Cage, - currCageMods: ConnectedModType[] -): CageModificationsType => { - const mods = deepCopyCageMods(cage.mods); - - const directKey = ModLocations.Direct; - const newDirectKeys = currCageMods.map(m => ({ - modId: m.modId, - parentModId: m.parentModId ?? null, - })); - - mods[directKey] = newDirectKeys.length - ? [{ subId: 1, modKeys: newDirectKeys }] - : []; - - return mods; -}; From db14e84acb7b36edd677bccf613aa95acce2a036 Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Wed, 15 Apr 2026 15:03:24 -0500 Subject: [PATCH 23/24] Add updated legend --- CageUI/resources/web/CageUI/static/legend.svg | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/CageUI/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index 049a54036..a64e32c8e 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -16,11 +16,9 @@ - * limitations under the License. - */ --> - - - - - + + @@ -29,12 +27,12 @@ - + - + @@ -59,7 +57,7 @@ - Solid Divider @@ -91,22 +89,39 @@ style="fill:#231f20; font-family:MyriadPro-Regular, 'Myriad Pro'; font-size:21px;"> Extension - + C-Tunnel - - - - + - + Social Panel Divider + + + Restraint + + + + + Window Blind + + + + \ No newline at end of file From 59676d8addd88923fb2e028387fafa8d0257f4fa Mon Sep 17 00:00:00 2001 From: LeviCameron1 Date: Thu, 16 Apr 2026 11:27:13 -0500 Subject: [PATCH 24/24] Update list styling --- CageUI/src/client/cageui.scss | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CageUI/src/client/cageui.scss b/CageUI/src/client/cageui.scss index 451242c3d..8f9f57618 100644 --- a/CageUI/src/client/cageui.scss +++ b/CageUI/src/client/cageui.scss @@ -1153,8 +1153,8 @@ .arrow { display: inline-block; - width: 10px; - height: 10px; + width: 15px; + height: 15px; border-top: 2px solid black; border-right: 2px solid black; transform: rotate(45deg); @@ -1172,26 +1172,31 @@ display: flex; align-items: center; justify-content: space-between; - font-size: large; + font-size: x-large; } .room-dir-room-obj { - margin: 10px 10px 10px 5px; + margin: 15px 10px 15px 5px; + border-bottom: 1px solid lightgrey; } .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; } .room-dir-cage-obj { cursor: pointer; display: flex; + font-size: large; align-items: center; justify-content: space-between; + margin: 15px 10px 15px 5px; } .room-dir-header.open .arrow { @@ -1207,7 +1212,7 @@ width: 100%; border: 3px solid #9DBFAF; padding: 5px; - height: 3vh; + height: 4vh; border-radius: 5px 0 0 5px; outline: none; }