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 = { 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/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index 9598caf23..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,101 +57,71 @@ - - 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 - - - - + - + - S - ocial - P - anel Divider + Social Panel Divider + + + Restraint + + + + + Window Blind + + + + \ No newline at end of file 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/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/cageui.scss b/CageUI/src/client/cageui.scss index 6c403b98b..8f9f57618 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) { @@ -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; } @@ -1475,7 +1480,7 @@ margin-top: 0px; background-color: lightblue; } -.cage-popup-overlay { +.room-display-popup-overlay { position: fixed; display: flex; top: 0; @@ -1492,7 +1497,7 @@ margin-top: 0px; touch-action: none; } -.cage-popup { +.room-display-popup { position: relative; z-index: 1000; background: white; @@ -1503,21 +1508,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 +1531,204 @@ margin-top: 0px; line-height: 1; } -.cage-popup-close:hover { +.room-display-popup-close:hover { color: #333; } -.cage-popup-content { +.room-display-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 { - 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 +1883,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 +1908,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 +2142,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 +2163,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/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/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 01467b5be..15d135ede 100644 --- a/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx +++ b/CageUI/src/client/components/home/cageView/CurrentCageLayout.tsx @@ -21,17 +21,18 @@ 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 {selectedRoom} = useHomeNavigationContext(); + 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('view', cage, cageSvg, selectedRoom, selectedRoom.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/rackView/RackViewContent.tsx b/CageUI/src/client/components/home/rackView/RackViewContent.tsx index 7e2232330..867ad179c 100644 --- a/CageUI/src/client/components/home/rackView/RackViewContent.tsx +++ b/CageUI/src/client/components/home/rackView/RackViewContent.tsx @@ -24,10 +24,10 @@ 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(); + 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..9d0fbbc2a 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,7 @@ 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, + ...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: selectedRoom.mods[key.modId].label, - value: selectedRoom.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: selectedRoom.mods[key.modId].label, - value: selectedRoom.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: selectedRoom.mods[key.modId].label, - value: selectedRoom.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: selectedRoom.mods[key.modId].label, - value: selectedRoom.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 046d851af..c4ab475f3 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -21,29 +21,30 @@ 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 { findCageInGroup, isCageModifier } from '../../../utils/LayoutEditorHelpers'; +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 } 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 { - 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 [prevCage, setPrevCage] = useState(null); const [currCage, setCurrCage] = useState(null); const [currRack, setCurrRack] = useState(null); const [currCageMods, setCurrCageMods] = useState(null); @@ -54,12 +55,24 @@ export const CagePopup: FC = (props) => { useEffect(() => { const tempCage = selectedObj as Cage; if (tempCage) { - const cageRack = findCageInGroup(tempCage.svgId, selectedRoom.rackGroups).rack; - setCurrCage(tempCage); + const cageRack = findCageInGroup(tempCage.svgId, selectedLocalRoom.rackGroups).rack; + 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 @@ -96,25 +109,76 @@ 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') { - 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 ( - 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/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/components/home/roomView/ModificationMultiSelect.tsx b/CageUI/src/client/components/home/roomView/ModificationMultiSelect.tsx index d578a7bfa..56209819c 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 { @@ -40,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(() => { @@ -50,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; } @@ -75,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(() => { @@ -86,17 +85,17 @@ 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); setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room mods', err); + console.error('Error fetching prev room mods', err); }); }, []); @@ -128,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(), }]); } @@ -141,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 ( @@ -158,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 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) => {
= (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 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) &&
} /> - ) :
-

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/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/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/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/types/typings.ts b/CageUI/src/client/types/typings.ts index de5bd92df..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'; @@ -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' }; @@ -210,7 +213,7 @@ export interface CageDimensions { } export interface RoomMods { - [key: ModIdKey]: Option; + [key: ModIdKey]: EHRCageMods; } export interface CurrCageMods { @@ -426,4 +429,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/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index 82bb7b32f..d339357a0 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'; @@ -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 { @@ -69,21 +69,65 @@ 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 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$/, ''); +} + +// Determines if the user has access to dragging the item +export const isDraggable = (user: GetUserPermissionsResponse, itemType: RoomItemType) => { + if(isRoomCreator(user) || isTemplateCreator(user)) { + return true; + } + if(isRoomModifier(user)){ + if (RoomObjectTypes.RoomDivider === itemType){ + return true; + } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === itemType){ + return true; + } + } + 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; + } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === 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; + } + if (RoomObjectTypes.GateOpen === itemType || RoomObjectTypes.GateClosed === itemType){ + return true; + } + } + return false; +} + -export const isCageModifier = (user: GetUserPermissionsResponse) => { - return Security.hasEffectivePermission(user.container.effectivePermissions, 'org.labkey.cageui.security.permissions.CageUIModificationEditorPermission'); -}; export const processRealLayoutHistory = async (data: LayoutHistoryData[]): Promise<{ fulfilled: FullObjectHistoryData[]; @@ -367,39 +411,113 @@ export function setupEditCageEvent( cageGroupElement: SVGGElement, setSelectedObj: React.Dispatch>, localRoomRef: MutableRefObject, - eventType: 'view' | 'edit', + eventType: "edit" | "view", 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 + }; + + const handleTouchMove = (event: TouchEvent) => { + if (event.touches.length !== 1) return; + + 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 context menu to the lowest level group for that cage. - cageGroupElement.style.pointerEvents = 'bounding-box'; + + if (eventType === 'edit') { cageGroupElement.addEventListener('contextmenu', handleContextMenu); + cageGroupElement.addEventListener('touchstart', handleTouchStart); + cageGroupElement.addEventListener('touchmove', handleTouchMove); + cageGroupElement.addEventListener('touchend', handleTouchEnd); } else { cageGroupElement.addEventListener('click', handleContextMenu); } @@ -407,12 +525,16 @@ export function setupEditCageEvent( return () => { if (eventType === 'edit') { cageGroupElement.removeEventListener('contextmenu', handleContextMenu); + cageGroupElement.removeEventListener('touchstart', handleTouchStart); + cageGroupElement.removeEventListener('touchmove', handleTouchMove); + cageGroupElement.removeEventListener('touchend', handleTouchEnd); } else { cageGroupElement.removeEventListener('click', handleContextMenu); } }; } + /* Helper function to either connect racks or merge cages @@ -449,7 +571,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, "edit", cageActionProps.setCtxMenuStyle); } // add starting x and y for each group to then increment its local subgroup coords by. @@ -776,7 +898,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 +923,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/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 diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 9936496c2..58555b31f 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -51,6 +51,7 @@ import { RoomObject, RoomObjectStringType, RoomObjectTypes, + SessionLog, TemplateHistoryData, UnitLocations, UnitType @@ -58,12 +59,14 @@ 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, createEmptyUnitLoc, findCageInGroup, + isDraggable, isRackEnum, isRoomHomogeneousDefault, placeAndScaleGroup, @@ -78,6 +81,32 @@ 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 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'); + 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 => { @@ -180,6 +209,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 @@ -409,7 +461,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`)}`); @@ -447,7 +498,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) { @@ -509,26 +560,16 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac .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); 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 +577,10 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac } cageGroup.append(() => shape.node()); + // attach context menu if user has permissions for cages + if(canOpenContextMenu(user, rack.type.type)){ + setupEditCageEvent(cageGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); + } }); @@ -561,34 +606,35 @@ 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); + } } }; // 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 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'); - let objSvg: SVGElement; + const roomObjGroup = wrapperGroup.append('g') + .attr('id', roomObj.itemId) + .attr('transform', `translate(0,0)`) - 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) => { - (roomObjGroup.node() as SVGElement).appendChild(d.documentElement); - }); - return; - } + let objSvg: SVGElement; + 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) @@ -596,9 +642,17 @@ 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())); + // Attach context menu if user has permissions for room objects + if(canOpenContextMenu(user, roomObj.type)){ + setupEditCageEvent(roomObjGroup.node(), setSelectedObj, contextMenuRef, mode, setCtxMenuStyle); + } + 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 createGroup(unitsToRender as RackGroup); @@ -758,13 +812,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,9 +820,15 @@ 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})); + const availMods = await cageModLookup([], []); const prevMods = prevRoom.modData.filter((mod) => mod.cage === cageData.objectId); prevMods.forEach((mod) => { @@ -1229,7 +1283,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; @@ -1299,7 +1353,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]; diff --git a/CageUI/src/client/utils/homeHelpers.ts b/CageUI/src/client/utils/homeHelpers.ts index d029f307c..573abfeab 100644 --- a/CageUI/src/client/utils/homeHelpers.ts +++ b/CageUI/src/client/utils/homeHelpers.ts @@ -18,18 +18,33 @@ import { Cage, - CageDirection, + CageDirection, CageModification, CageModificationsType, CageNumber, CurrCageMods, ModDirections, ModLocations, - ModTypes, + ModTypes, Room, RoomMods } from '../types/typings'; -import { Option } from '@labkey/components'; -import { cageModLookup } from '../api/popularQueries'; -import { 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 +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) => { @@ -113,4 +128,163 @@ 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] = {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) + 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] = {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, + 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]: [], +}); 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/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; 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; + } +} 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