From 46a19aeb6e5556ae83f2f4587426e29d1ff9d3ef Mon Sep 17 00:00:00 2001 From: LeviCameron1 <86750204+LeviCameron1@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:37:52 -0500 Subject: [PATCH] More CageUI Fixes (#948) * Bugfix for template renaming * Bug fix for context menu disappearing from the page if it was positioned to close to the edge * update watch config to get correct path for ios testing * bug fix for layout editor not adding mods to real room after loading in from template * Update legend text spacing * Add group svg wrapper to room objects allowing them to have context menus on mobile devices * Bug fix for gate switching object after adding groups * Fix deletion and movement for room objects * Update permissions for tables * Add permissions to drag objects across layout * Add permissions for adding to layout, updating border size, clearing layout, and opening context menus * Add session log for forms * Fix bug with opening menu * remove console logs, add console errors instead for error messages * Add permissions to edit the room * Add restraint modification and window blind modification * Add room object context menu and gate saving * Update RoomLayout.tsx * Fix bug with the svg loading not working for cage popups * Add new mods to java types * Bug fix for save a cage with no separator style mods causing issues If a user submitted a mod after saving the table with no mod between cages this would break the mods from loading. This change ensures that default mods are added after the user saves the current mods for a cage, if no separator mods exist in that section. * Remove room objects depending on permissions instead of blocking them with an error. * Add updated legend * Update list styling * Remove commented effect from cagePopup --- CageUI/resources/web/CageUI/static/cage.svg | 9 + CageUI/resources/web/CageUI/static/legend.svg | 43 ++-- CageUI/src/client/api/labkeyActions.ts | 6 +- CageUI/src/client/api/popularQueries.ts | 2 +- CageUI/src/client/cageui.scss | 229 ++++++++++++++---- .../components/home/HomeViewContent.tsx | 3 +- .../home/cageView/CageViewContent.tsx | 4 +- .../home/cageView/CurrentCageLayout.tsx | 9 +- .../home/rackView/RackViewContent.tsx | 6 +- .../home/roomView/CageModifications.tsx | 19 +- .../components/home/roomView/CagePopup.tsx | 109 ++++++--- .../components/home/roomView/GateEditor.tsx | 72 ++++++ .../home/roomView/ModificationEditor.tsx | 19 +- .../home/roomView/ModificationMultiSelect.tsx | 29 ++- .../components/home/roomView/RoomLayout.tsx | 86 ++++--- .../home/roomView/RoomObjectPopup.tsx | 119 +++++++++ .../home/roomView/RoomViewContent.tsx | 22 +- .../client/components/layoutEditor/Editor.tsx | 199 ++++++++------- .../layoutEditor/GateChangeRoom.tsx | 2 +- .../components/layoutEditor/GateSwitch.tsx | 15 +- .../layoutEditor/RoomSelectorPopup.tsx | 2 +- .../context/HomeNavigationContextManager.tsx | 10 +- .../context/LayoutEditorContextManager.tsx | 30 ++- .../src/client/context/RoomContextManager.tsx | 87 ++++++- .../pages/layoutEditor/LayoutEditor.tsx | 32 ++- .../types/homeNavigationContextTypes.ts | 4 +- CageUI/src/client/types/homeTypes.ts | 2 +- CageUI/src/client/types/roomContextTypes.ts | 6 +- CageUI/src/client/types/typings.ts | 33 ++- .../src/client/utils/LayoutEditorHelpers.ts | 200 ++++++++++++--- CageUI/src/client/utils/constants.ts | 36 +++ CageUI/src/client/utils/helpers.ts | 125 +++++++--- CageUI/src/client/utils/homeHelpers.ts | 186 +++++++++++++- .../org/labkey/cageui/CageUIController.java | 43 +++- .../src/org/labkey/cageui/CageUIManager.java | 56 +++++ .../src/org/labkey/cageui/model/ModTypes.java | 4 +- .../org/labkey/cageui/model/SessionLog.java | 127 ++++++++++ .../labkey/cageui/query/AllHistoryTable.java | 10 +- .../labkey/cageui/query/CageHistoryTable.java | 12 +- .../query/CageModificationsHistoryTable.java | 12 +- .../cageui/query/CageModificationsTable.java | 6 +- .../org/labkey/cageui/query/CagesTable.java | 14 +- .../cageui/query/LayoutHistoryTable.java | 14 +- .../labkey/cageui/query/RackTypesTable.java | 13 +- .../org/labkey/cageui/query/RacksTable.java | 14 +- .../labkey/cageui/query/RoomHistoryTable.java | 12 +- .../query/TemplateLayoutHistoryTable.java | 6 +- .../roles/CageUIRoomModifierRole.java | 1 + 48 files changed, 1659 insertions(+), 440 deletions(-) create mode 100644 CageUI/src/client/components/home/roomView/GateEditor.tsx create mode 100644 CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx create mode 100644 CageUI/src/org/labkey/cageui/model/SessionLog.java 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 049a54036..a64e32c8e 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -16,11 +16,9 @@ - * limitations under the License. - */ --> - - - - - + + @@ -29,12 +27,12 @@ - + - + @@ -59,7 +57,7 @@ - Solid Divider @@ -91,22 +89,39 @@ style="fill:#231f20; font-family:MyriadPro-Regular, 'Myriad Pro'; font-size:21px;"> Extension - + C-Tunnel - - - - + - + Social Panel Divider + + + Restraint + + + + + Window Blind + + + + \ No newline at end of file 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..6d8ce82d3 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,15 @@ 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(() => { // Check if the click was outside the menu @@ -96,25 +100,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 1ce43272a..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); @@ -774,8 +828,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit [ModLocations.Direct]: [] }; - const modReturnData = await cageModLookup([], []); - const availMods = modReturnData.map(row => ({value: row.value, label: row.title})); + const availMods = await cageModLookup([], []); const prevMods = prevRoom.modData.filter((mod) => mod.cage === cageData.objectId); prevMods.forEach((mod) => { @@ -1230,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; @@ -1300,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