= (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 e7177fc47..a5931267d 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,64 +33,73 @@ 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} = 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);
// 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('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]);
+ /* 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 (!selectedRoom.mods || !selectedRoomMods) {
- return;
- }
- setShowChangesMenu(!(_.isEqual(selectedRoomMods, selectedRoom.mods)));
- }, [selectedRoom.mods]);
-
+ const modsEqual = _.isEqual(selectedRoomMods, selectedLocalRoom.mods);
+ const objectsEqual = _.isEqual(selectedRoom?.objects, selectedLocalRoom.objects);
+ setShowChangesMenu(!modsEqual || !objectsEqual);
+ }, [selectedRoom, selectedLocalRoom, selectedRoomMods]);
const saveLayout = async () => {
@@ -99,7 +108,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 +150,9 @@ export const RoomLayout: FC = (props) => {
- 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 eb2ca0a98..7f76826fc 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, selectedLocalRoom, userProfile} = useHomeNavigationContext();
const roomName = selectedPage?.room;
const handleLayoutEdit = () => {
@@ -43,30 +45,40 @@ export const RoomViewContent: FC = (props) => {
selectedPage &&
+ {/* Hide room valid for now, it could be misleading until we add room validations
+ />*/}
{roomName}
+
+ {canEditLayout(userProfile) &&
+
+ }
:
{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 be4b01af1..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,
@@ -47,6 +48,8 @@ import {
import { LayoutTooltip } from './LayoutTooltip';
import {
areCagesInSameRack,
+ canOpenContextMenu,
+ canPlaceObject,
checkAdjacent,
createDragInLayout,
createEmptyUnitLoc,
@@ -58,9 +61,8 @@ import {
findRackInGroup,
getLayoutOffset,
getTargetRect,
+ isDraggable,
isRackEnum,
- isRoomCreator,
- isTemplateCreator,
mergeRacks,
parseWrapperId,
placeAndScaleGroup,
@@ -72,6 +74,8 @@ import {
import {
addPrevRoomSvgs,
getNextDefaultRackId,
+ isRoomCreator,
+ isTemplateCreator,
parseRoomItemNum,
parseRoomItemType,
roomItemToString,
@@ -190,11 +194,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('class', `draggable room-obj type-${roomItemToString(updateItemType)}`)
+ .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;
@@ -217,21 +224,14 @@ const Editor: FC = ({roomSize}) => {
}
placeAndScaleGroup(group, cellX, cellY, transform);
+ // attach drag if user has permissions
+ if(isDraggable(user, updateItemType)){
+ group.call(closeMenuThenDrag);
- 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);
+ }
+ // attach context menu if user has permissions
+ if(canOpenContextMenu(user, updateItemType)){
+ setupEditCageEvent(group.node().firstChild, setSelectedObj, contextMenuRef,"edit", setCtxMenuStyle);
}
dragLockRef.current = false;
@@ -647,7 +647,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);
@@ -656,8 +657,10 @@ const Editor: FC = ({roomSize}) => {
CELL_SIZE,
borderGroup,
setLocalRoom
- )
- );
+ )
+ );
+ }
+
// Set zoom after border is loaded in
zoomToScale(roomSize.scale);
@@ -686,7 +689,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]);
@@ -792,14 +795,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);
}
});
@@ -875,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) &&
+
+
+
+ }
@@ -974,12 +997,14 @@ const Editor: FC = ({roomSize}) => {
data-tg-on="Grid Enabled"
htmlFor="cb3-8">
-
+ {(isRoomCreator(user) || isTemplateCreator(user)) &&
+
+ }
{isTemplateCreator(user) &&