diff --git a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql new file mode 100644 index 000000000..e995899e5 --- /dev/null +++ b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql @@ -0,0 +1,57 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +INSERT INTO ehr_lookups.lookup_sets (setname, label, description, keyField, container) +select 'cageui_svg_urls' as setname, + 'SVG Urls Field Values' as label, + 'List of URLS for room items' as description, + 'value' as keyField, + container from ehr_lookups.lookup_sets where setname='ancestry'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'cage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'pen' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'tempCage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'playCage' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'roomDivider' as value, '/cageui/static/roomDivider.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'drain' as value, '/cageui/static/drain.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'door' as value, '/cageui/static/door.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateClosed' as value, '/cageui/static/gateClosed.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateOpen' as value, '/cageui/static/gateOpen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'top' as value, '/cageui/static/top.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'bottom' as value, '/cageui/static/bottom.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; diff --git a/CageUI/resources/web/CageUI/static/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..51a97407f 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -16,97 +16,119 @@ - * limitations under the License. - */ --> - - - - - + + - - - - - - + + - - - - - + + + + - - - - - + - - + - - - - - - - + + + - Solid Divider + Solid Divider - - Protected Contact Divider + Protected Contact Divider - - Visual Contact Divider + Visual Contact Divider - - Privacy Divider + Privacy Divider - - Standard Floor + Standard Floor - - Mesh Floor + Mesh Floor - - Mesh Floor x2 + Mesh Floor x2 - Extension - + C-Tunnel - - - - + - - - + - Social Panel Divider + Social Panel Divider + + + + Restraint + + + + + Window Blind + + + + + + + + Locked Divider \ 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..a3b6bc49a 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) { @@ -1148,13 +1148,13 @@ .room-list-items { overflow-y: auto; - padding: 5px; + padding: 5px 15px 5px 5px; } .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); @@ -1166,40 +1166,57 @@ border: 1px solid black; } +.room-dir-header-container { + display: flex; + align-items: center; + justify-content: space-between; +} + .room-dir-header { cursor: pointer; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; - font-size: large; + font-size: x-large; + flex-grow: 1; } .room-dir-room-obj { - margin: 10px 10px 10px 5px; + margin: 15px 10px 15px 5px; + border-bottom: 1px solid lightgrey; +} + +.room-dir-rack-obj-container { + display: flex; + align-items: center; + justify-content: space-between; } .room-dir-rack-obj { cursor: pointer; + font-size: large; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; + margin: 15px 10px 15px 5px; + flex-grow: 1; } .room-dir-cage-obj { cursor: pointer; display: flex; + font-size: large; align-items: center; justify-content: space-between; + margin: 15px 10px 15px 5px; } -.room-dir-header.open .arrow { +.room-dir-header-container.open .arrow { transform: rotate(135deg); } -.room-dir-rack-obj.open .arrow { +.room-dir-rack-obj-container.open .arrow { transform: rotate(135deg); } @@ -1207,7 +1224,7 @@ width: 100%; border: 3px solid #9DBFAF; padding: 5px; - height: 3vh; + height: 4vh; border-radius: 5px 0 0 5px; outline: none; } @@ -1475,7 +1492,7 @@ margin-top: 0px; background-color: lightblue; } -.cage-popup-overlay { +.room-display-popup-overlay { position: fixed; display: flex; top: 0; @@ -1492,7 +1509,7 @@ margin-top: 0px; touch-action: none; } -.cage-popup { +.room-display-popup { position: relative; z-index: 1000; background: white; @@ -1503,21 +1520,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 +1543,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 +1895,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 +1920,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 +2154,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 +2175,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/LoadingScreen.tsx b/CageUI/src/client/components/LoadingScreen.tsx index 821ffdc42..d7c2d91ad 100644 --- a/CageUI/src/client/components/LoadingScreen.tsx +++ b/CageUI/src/client/components/LoadingScreen.tsx @@ -22,11 +22,12 @@ import { createPortal } from 'react-dom'; interface LoadingScreenProps { isVisible: boolean; + message: string; targetElement?: HTMLElement | null; } export const LoadingScreen: FC = (props) => { - const {isVisible, targetElement} = props; + const {isVisible, message, targetElement} = props; const [container, setContainer] = useState(null); @@ -44,7 +45,7 @@ export const LoadingScreen: FC = (props) => {
-

Saving...

+

{message}

, container diff --git a/CageUI/src/client/components/home/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/RoomContent.tsx b/CageUI/src/client/components/home/RoomContent.tsx index 750d30bdc..e057356fe 100644 --- a/CageUI/src/client/components/home/RoomContent.tsx +++ b/CageUI/src/client/components/home/RoomContent.tsx @@ -24,9 +24,10 @@ import { CageViewContent } from './cageView/CageViewContent'; import { RackViewContent } from './rackView/RackViewContent'; import { HomeViewContent } from './HomeViewContent'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; +import { LoadingScreen } from '../LoadingScreen'; export const RoomContent: FC = () => { - const {selectedPage} = useHomeNavigationContext(); + const {selectedPage, isNavLoading} = useHomeNavigationContext(); const renderContent = () => { switch (selectedPage?.selected) { @@ -43,7 +44,12 @@ export const RoomContent: FC = () => { return (
- {renderContent()} + + {!isNavLoading && renderContent()}
); }; \ No newline at end of file diff --git a/CageUI/src/client/components/home/RoomList.tsx b/CageUI/src/client/components/home/RoomList.tsx index a6a58c633..98bbed5f9 100644 --- a/CageUI/src/client/components/home/RoomList.tsx +++ b/CageUI/src/client/components/home/RoomList.tsx @@ -17,26 +17,29 @@ */ import * as React from 'react'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import '../../cageui.scss'; import { Room } from '../../types/typings'; import { ExpandedRooms, ListCage, ListRack, ListRoom } from '../../types/homeTypes'; import { labkeyActionSelectWithPromise } from '../../api/labkeyActions'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; -import { Filter } from '@labkey/api'; +import { ActionURL, Filter } from '@labkey/api'; export const RoomList: FC = () => { - const {navigateTo} = useHomeNavigationContext(); + const {navigateTo, selectedPage, setIsNavLoading} = useHomeNavigationContext(); // keeps track of which rooms have already been fetched from layout_history const [expandedRooms, setExpandedRooms] = useState({}); - const [expandedRacks, setExpandedRacks] = useState([]); + const [expandedRacks, setExpandedRacks] = useState>({}); const [allRooms, setAllRooms] = useState([]); // Stores all items fetched on load const [visibleRooms, setVisibleRooms] = useState([]); // Items currently visible const [searchQuery, setSearchQuery] = useState(''); + const roomRefs = useRef>({}); + const listContainerRef = useRef(null); + const handleSearch = (e) => { setSearchQuery(e.target.value); }; @@ -120,6 +123,18 @@ export const RoomList: FC = () => { }); }); }); + + // Sort cages within each rack and then sort racks by their first cage + tempRacks.forEach((rack) => { + rack.cages.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + }); + tempRacks.sort((a, b) => { + if (a.cages.length > 0 && b.cages.length > 0) { + return a.cages[0].name.localeCompare(b.cages[0].name, undefined, { numeric: true }); + } + return 0; + }); + return { ...prevRoom, racks: tempRacks, @@ -144,15 +159,63 @@ export const RoomList: FC = () => { })); }; + // Auto-expand and scroll based on URL parameters + useEffect(() => { + const roomName = ActionURL.getParameter("room"); + const rackId = ActionURL.getParameter("rack"); + + if (roomName) { + if (!expandedRooms[roomName]) { + toggleExpandRoom(roomName); + } + + if (rackId) { + const rackKey = `${roomName}_${rackId}`; + if (!expandedRacks[rackKey]) { + setExpandedRacks(prev => ({ + ...prev, + [rackKey]: true + })); + } + } + + // Scroll room into view + if (roomRefs.current[roomName] && listContainerRef.current) { + const container = listContainerRef.current; + const element = roomRefs.current[roomName]; + + // Use a short timeout to ensure the DOM has updated (expanded) before we calculate the offset + setTimeout(() => { + if (element && container) { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + // elementRect.top is the distance from viewport top to element top + // containerRect.top is the distance from viewport top to container top + // relativeTop is the distance from container top to element top within the scrollable area + const relativeTop = elementRect.top - containerRect.top + container.scrollTop; + + container.scrollTo({ + top: relativeTop, + behavior: 'auto' + }); + } + }, 100); + } + } + }, [selectedPage, allRooms, visibleRooms]); + const handleRoomClick = (room: ListRoom) => { + setIsNavLoading(true); navigateTo({selected: 'Room', room: room.name}) }; const handleRackClick = (room: ListRoom, rack: ListRack) => { + setIsNavLoading(true); navigateTo({selected: 'Rack', room: room.name, rack: rack.id}); }; const handleCageClick = (room: ListRoom, rack: ListRack, cage: ListCage) => { + setIsNavLoading(true); navigateTo({selected: 'Cage', room: room.name, rack: rack.id, cage: cage.id}); }; @@ -165,25 +228,35 @@ export const RoomList: FC = () => { className={'room-search'} onChange={handleSearch} /> -
    +
      {visibleRooms.map((room, index) => ( -
      -
      handleRoomClick(room)} - className={`room-dir-header ${expandedRooms[room.name] ? 'open' : ''}`} - > - {room.name} +
      { + if (el) roomRefs.current[room.name] = el; + }} + > +
      +
      handleRoomClick(room)} + className={`room-dir-header`} + > + {room.name} +
      toggleExpandRoom(room.name)}>
      {expandedRooms[room.name] && (
        {room?.racks?.map((rack) => (
      • -
        handleRackClick(room, rack)} - className={`room-dir-rack-obj ${expandedRacks[`${room.name}_${rack.id}`] ? 'open' : ''}`} - > - {rack.name} +
        +
        handleRackClick(room, rack)} + className={`room-dir-rack-obj`} + > + {rack.name} +
        toggleExpandRack(room.name, rack.id)}>
        diff --git a/CageUI/src/client/components/home/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/ChangeRackPopup.tsx b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx index 3fa788b35..821258ec0 100644 --- a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx +++ b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx @@ -147,7 +147,7 @@ export const ChangeRackPopup: FC = (props) => { //navigateTo({selected: 'Room', room: selectedRoom.name}); window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-home', + 'home', ActionURL.getContainer(), {room: res.roomName, rack: res.rack}); @@ -167,6 +167,7 @@ export const ChangeRackPopup: FC = (props) => { {isSaving && } diff --git a/CageUI/src/client/components/home/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..c206d5d86 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,75 @@ 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')); + 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..cadb12a8e 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); @@ -113,8 +122,12 @@ export const RoomLayout: FC = (props) => { return (
        - {isSaving && }
        {showChangesMenu && @@ -141,9 +154,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..40dad79c8 --- /dev/null +++ b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx @@ -0,0 +1,115 @@ +/* + * + * * 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(() => { + // 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..7f6b165f6 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,8 +689,9 @@ const Editor: FC = ({roomSize}) => { } }); // loads grid with new room - addPrevRoomSvgs('edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag); - setReloadRoom(null); + addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag).then(() => { + setReloadRoom(null); + }); }, [reloadRoom]); // Effect attaches an observer to the border_template svg. after it is injected into the dom it will run @@ -747,8 +751,8 @@ const Editor: FC = ({roomSize}) => { if (loadTemplate) { window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-editLayout', - ActionURL.getContainer(), + 'editLayout', + ActionURL.getController(), {room: localRoom.name} ); } @@ -792,14 +796,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); } }); @@ -870,67 +875,87 @@ const Editor: FC = ({roomSize}) => { {startSaving && }
        - - - - - - - - - - - - - - - - - - - - - + {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 +999,14 @@ const Editor: FC = ({roomSize}) => { data-tg-on="Grid Enabled" htmlFor="cb3-8">
        - + {(isRoomCreator(user) || isTemplateCreator(user)) && + + } {isTemplateCreator(user) && + + {(selectedRackGroup && selectedRackGroup.racks.length > 1) && + + }
        }
        diff --git a/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx b/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx index c4231920a..7ee10e537 100644 --- a/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx +++ b/CageUI/src/client/components/layoutEditor/GateChangeRoom.tsx @@ -68,7 +68,7 @@ export const GateChangeRoom: FC = (props) => { setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room', err); + console.error('Error fetching prev room', err); }); }, []); diff --git a/CageUI/src/client/components/layoutEditor/GateSwitch.tsx b/CageUI/src/client/components/layoutEditor/GateSwitch.tsx index aed214112..769b880ea 100644 --- a/CageUI/src/client/components/layoutEditor/GateSwitch.tsx +++ b/CageUI/src/client/components/layoutEditor/GateSwitch.tsx @@ -23,18 +23,17 @@ import { Room, RoomObject, RoomObjectTypes } from '../../types/typings'; import { parseRoomItemNum } from '../../utils/helpers'; interface GateSwitchProps { - layoutSvg: d3.Selection; selectedObj: RoomObject; setLocalRoom: React.Dispatch>; + setReloadRoom: React.Dispatch>; closeMenu: () => void; } export const GateSwitch: FC = (props) => { - const {layoutSvg, selectedObj, setLocalRoom, closeMenu} = props; + const {selectedObj, setLocalRoom, closeMenu, setReloadRoom} = props; // For each open or close, remove gate svg template of the opposite and replace with new version. Also switch id name version keeping id number const handleClick = () => { - const gateSvg = layoutSvg.select(`#${selectedObj.itemId}`); let newGateIdPrefix; if (selectedObj.type === RoomObjectTypes.GateOpen) { newGateIdPrefix = 'gateClosed'; @@ -42,13 +41,8 @@ export const GateSwitch: FC = (props) => { newGateIdPrefix = 'gateOpen'; } - const newGateSvg = (d3.select(`#${newGateIdPrefix}_template_wrapper`) as d3.Selection).node().cloneNode(true) as SVGElement; - gateSvg.selectChild().remove(); - gateSvg.append(() => newGateSvg); - gateSvg.attr('id', `${newGateIdPrefix}-${parseRoomItemNum((selectedObj as RoomObject).itemId)}`); - setLocalRoom(prevState => { - return { + const newRoom = { ...prevState, objects: prevState.objects.map((obj) => { if (obj.itemId === selectedObj.itemId) { @@ -61,7 +55,10 @@ export const GateSwitch: FC = (props) => { return obj; }) }; + setReloadRoom(newRoom); + return newRoom; }); + closeMenu(); }; return ( diff --git a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx index d879ca37b..26425ef3b 100644 --- a/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx +++ b/CageUI/src/client/components/layoutEditor/RoomSelectorPopup.tsx @@ -60,7 +60,7 @@ export const RoomSelectorPopup: FC = (props) => { setOptions(rowOptions); } }).catch(err => { - console.log('Error fetching prev room', err); + console.error('Error fetching prev room', err); }); }, []); diff --git a/CageUI/src/client/context/HomeNavigationContextManager.tsx b/CageUI/src/client/context/HomeNavigationContextManager.tsx index 0b4177c06..416fa3392 100644 --- a/CageUI/src/client/context/HomeNavigationContextManager.tsx +++ b/CageUI/src/client/context/HomeNavigationContextManager.tsx @@ -51,13 +51,18 @@ export const HomeNavigationContextProvider: FC = ({u const [userProfile, setUserProfile] = useState(user); const [selectedRoom, setSelectedRoom] = useState(null); + const [selectedLocalRoom, setSelectedLocalRoom] = useState(null); const [selectedRoomMods, setSelectedRoomMods] = useState({}); const [selectedRackGroup, setSelectedRackGroup] = useState(null); const [selectedRack, setSelectedRack] = useState(null); const [selectedCage, setSelectedCage] = useState(null); - // Track if we've already handled this specific URL + const [isNavLoading, setIsNavLoading] = useState(false); + + useEffect(() => { + setSelectedLocalRoom(selectedRoom); + }, [selectedRoom]); // Load initial data based on URL parameters useEffect(() => { @@ -119,11 +124,14 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(null); setSelectedRack(null); setSelectedCage(null); + setIsNavLoading(false); break; case 'Room': if (page.room) { - loadRoomData(page.room); + loadRoomData(page.room).then((newRoom) => { + setIsNavLoading(false); + }); } break; @@ -135,11 +143,13 @@ export const HomeNavigationContextProvider: FC = ({u const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, newRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); }); } else { const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, selectedRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); } } break; @@ -157,6 +167,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); }); } else { const { @@ -167,6 +178,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); } } break; @@ -197,7 +209,7 @@ export const HomeNavigationContextProvider: FC = ({u newLocalRoom.layoutData = roomData.prevRoomData.layoutData; // Ensure they don't share the same reference (using lodash to clone) setSelectedRoomMods(_.cloneDeep(newLocalRoom.mods)); - setSelectedRoom(newLocalRoom); + setSelectedRoom({...newLocalRoom, objects: [...newLocalRoom.objects]}); } return newLocalRoom; } else { @@ -223,11 +235,14 @@ export const HomeNavigationContextProvider: FC = ({u selectedRoomMods, selectedRackGroup, selectedRoom, + selectedLocalRoom, selectedRack, selectedCage, navigateTo, - setSelectedRoom, + setSelectedLocalRoom, userProfile, + isNavLoading, + setIsNavLoading }}> {children} diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index 33f629e1c..58f6ba7ad 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -39,11 +39,18 @@ import { RoomObject, RoomObjectTypes, UnitLocations, - UnitType + UnitType, + SessionLog } from '../types/typings'; -import { CellKey, DeleteActions, LayoutSaveResult, RackActions, SelectedObj } from '../types/layoutEditorTypes'; import { - createEmptyUnitLoc, + CellKey, + DeleteActions, + LayoutSaveResult, + RackActions, + SelectedObj, +} from '../types/layoutEditorTypes'; +import { + createEmptyUnitLoc, extractRoomObjId, findCageInGroup, findRackInGroup, findSelectObjRack, @@ -51,11 +58,14 @@ import { getTranslation, isRackEnum, showLayoutEditorError, + checkAdjacent } from '../utils/LayoutEditorHelpers'; import * as d3 from 'd3'; import { + cageDirectionToModLocation, generateCageId, generateUUID, + getAdjLocation, getNextDefaultRackId, getSvgSize, parseLongId, @@ -63,7 +73,7 @@ import { parseRoomItemType, rackTypeToDefaultType, roomItemToString, - saveRoomHelper + saveRoomHelper, toLabKeyDate } from '../utils/helpers'; import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; import { Filter } from '@labkey/api'; @@ -85,6 +95,12 @@ export const useLayoutEditorContext = () => { }; export const LayoutEditorContextProvider: FC = ({children, prevRoom, user}) => { + const [sessionLog, setSessionLog] = useState({ + startTime: toLabKeyDate(new Date()), + userAgent: navigator.userAgent, + schemaName: 'cageui', + queryName: 'layout_history' + }); // loaded in and unchanged since start of layout editing const [room, setRoom] = useState({ name: 'new-layout', @@ -138,7 +154,6 @@ export const LayoutEditorContextProvider: FC = ({children, p // instead of tying scale to each location, manage one scale for the whole layout const [scale, setScale] = useState(1); - const grid = useRef>(new Map()); const getCageLoc = (cageId: CageSvgId, cageNum: CageNumber): LocationCoords => { @@ -277,6 +292,15 @@ export const LayoutEditorContextProvider: FC = ({children, p }); }; + const getNewGroupId = () => { + const newId = nextAvailGroup; + setNextAvailGroup(prevState => { + const nextId = parseLongId(prevState) + 1; + return `rack-group-${nextId}` as GroupId; + }); + return newId; + } + // This only adds default racks/cages to the layout, it is not used in loading in previous layouts const addRack = async (id: number, x: number, y: number, newScale: number, rackType: RackTypes): Promise => { const newCageNum: CageNumber = `${roomItemToString(rackType) as RackStringType}-${getNextCageNum(roomItemToString(rackType) as RackStringType)}`; @@ -355,7 +379,7 @@ export const LayoutEditorContextProvider: FC = ({children, p const newRackGroup: RackGroup = { selectionType: 'rackGroup', - groupId: nextAvailGroup, + groupId: getNewGroupId(), racks: [newRack], rotation: GroupRotation.Quarter, x: x, @@ -363,10 +387,6 @@ export const LayoutEditorContextProvider: FC = ({children, p scale: newScale, }; - setNextAvailGroup(prevState => { - const nextId = parseLongId(prevState) + 1; - return `rack-group-${nextId}` as GroupId; - }); setLocalRoom(prevRoom => ({ ...prevRoom, rackGroups: [...prevRoom.rackGroups, newRackGroup] @@ -592,7 +612,7 @@ export const LayoutEditorContextProvider: FC = ({children, p // Find the moved rack to access its cages if (!movedRack) { - console.log('Failed to update cages location for rack'); + console.error('Failed to update cages location for rack'); return prevRoom; // cannot find an available rack id to move } @@ -643,7 +663,7 @@ export const LayoutEditorContextProvider: FC = ({children, p // Find the moved rack to access its cages if (movedRacks.length === 0) { - console.log('Failed to update cages location for rack'); + console.error('Failed to update cages location for rack'); return prevRoom; // cannot find an available rack id to move } setUnitLocs((prevUnitLocations) => { @@ -676,7 +696,7 @@ export const LayoutEditorContextProvider: FC = ({children, p updatedLocalRoom = { ...prevRoom, objects: prevRoom.objects.map(item => - item.itemId === itemId + item.itemId === extractRoomObjId(itemId) ? {...item, x, y, scale: k} : item ) @@ -832,7 +852,6 @@ export const LayoutEditorContextProvider: FC = ({children, p // 6. Split groups based on components let finalGroups = updatedGroups.filter(g => g.groupId !== location.rackGroup.groupId); - let nextGroupId = nextAvailGroup; // In the group splitting logic: if (components.size > 1) { @@ -936,15 +955,11 @@ export const LayoutEditorContextProvider: FC = ({children, p finalGroups.push({ ...affectedGroup, - groupId: nextGroupId, + groupId: getNewGroupId(), x: minX, y: minY, racks: newRacks }); - - // Update next group ID - const nextIdNum = parseInt(nextGroupId.split('-')[2]) + 1; - nextGroupId = `rack-group-${nextIdNum}` as GroupId; } } else { // No splitting needed, keep the modified group @@ -952,7 +967,6 @@ export const LayoutEditorContextProvider: FC = ({children, p } // 7. Update state - setNextAvailGroup(nextGroupId); setLocalRoom(prev => ({ ...prev, rackGroups: finalGroups @@ -1125,6 +1139,111 @@ export const LayoutEditorContextProvider: FC = ({children, p setCageNumChange({before: numBefore, after: numAfter}); }; + /* + Effectively unconnects the selectedRack from any connections with other racks. It does this by removing it from + the current rack group and creating a new rack group for the selected rack. + */ + const unmergeRacks = (rackGroup: RackGroup, selectedRack: Rack) => { + const newRoom: Room = { ...localRoom }; + + // 1. Find the index of the rack group that contains the selected rack + const rackGroupIndex = newRoom.rackGroups.findIndex(group => + group.groupId === rackGroup.groupId + ); + + if (rackGroupIndex === -1) return; + + const removedModIds: string[] = []; + const otherRacks = rackGroup.racks.filter(r => r.objectId !== selectedRack.objectId); + + // Process modifications between selectedRack and other racks in the group + selectedRack.cages.forEach(selectedCage => { + const selectedCageLoc = getCageLoc(selectedCage.svgId, selectedCage.cageNum); + if (!selectedCageLoc) return; + + otherRacks.forEach(otherRack => { + otherRack.cages.forEach(otherCage => { + const otherCageLoc = getCageLoc(otherCage.svgId, otherCage.cageNum); + if (!otherCageLoc) return; + + const adjResult = checkAdjacent(otherCageLoc, selectedCageLoc, selectedCage.size, otherCage.size); + if (adjResult.isAdjacent) { + const location = cageDirectionToModLocation(adjResult.direction, rackGroup.rotation); + const adjLocation = getAdjLocation(location); + + // Remove from selectedCage + if (selectedCage.mods && selectedCage.mods[location]) { + selectedCage.mods[location].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + selectedCage.mods[location] = []; + } + + // Remove from otherCage + if (otherCage.mods && otherCage.mods[adjLocation]) { + otherCage.mods[adjLocation].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + otherCage.mods[adjLocation] = []; + } + } + }); + }); + }); + + // Remove collected modIds from room.mods + if (newRoom.mods) { + removedModIds.forEach(id => { + delete newRoom.mods[id]; + }); + } + + // 2. Create the updated original rack group (without the selected rack) + let updatedOriginalRacks = otherRacks; + let updatedOriginalGroup = { ...rackGroup }; + + if (updatedOriginalRacks.length > 0) { + // Normalize the original group: find the new top-left corner + const minX = Math.min(...updatedOriginalRacks.map(r => r.x)); + const minY = Math.min(...updatedOriginalRacks.map(r => r.y)); + + updatedOriginalGroup = { + ...rackGroup, + x: rackGroup.x + minX, + y: rackGroup.y + minY, + racks: updatedOriginalRacks.map(r => ({ + ...r, + x: r.x - minX, + y: r.y - minY + })) + }; + newRoom.rackGroups[rackGroupIndex] = updatedOriginalGroup; + } else { + // If no racks left, remove the group entirely + newRoom.rackGroups.splice(rackGroupIndex, 1); + } + + // 3. Create the new rack group for the unmerged rack + // The new group starts at the global position of the selected rack + const newRackGroup: RackGroup = { + ...rackGroup, + groupId: getNewGroupId(), + x: rackGroup.x + selectedRack.x, + y: rackGroup.y + selectedRack.y, + racks: [{ + ...selectedRack, + x: 0, // Reset local coordinates to 0,0 in the new group + y: 0 + }] + }; + + // 4. Update the room state + newRoom.rackGroups = [...newRoom.rackGroups, newRackGroup]; + + setLocalRoom(newRoom); + setReloadRoom(newRoom); + }; + const getNextCageNum = (rackType: RackStringType) => { const cages = unitLocs[rackType]; @@ -1153,7 +1272,7 @@ export const LayoutEditorContextProvider: FC = ({children, p }; const saveRoom = async (oldTemplateName?: string): Promise => { - return saveRoomHelper(localRoom, oldTemplateName); + return saveRoomHelper(localRoom, sessionLog, oldTemplateName ); }; useEffect(() => { @@ -1199,7 +1318,8 @@ export const LayoutEditorContextProvider: FC = ({children, p user, getAdjCages, reloadRoom, - setReloadRoom + setReloadRoom, + unmergeRacks }}> {!isLoading ? children : null} diff --git a/CageUI/src/client/context/RoomContextManager.tsx b/CageUI/src/client/context/RoomContextManager.tsx index a4fc95346..9ec43d0a6 100644 --- a/CageUI/src/client/context/RoomContextManager.tsx +++ b/CageUI/src/client/context/RoomContextManager.tsx @@ -17,10 +17,10 @@ */ import * as React from 'react'; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useState } from 'react'; import { RoomContextType } from '../types/roomContextTypes'; -import { getAdjLocation, saveRoomHelper } from '../utils/helpers'; +import { getAdjLocation, saveRoomHelper, toLabKeyDate } from '../utils/helpers'; import { Cage, CageModification, @@ -30,12 +30,13 @@ import { Rack, RackConditionOption, Room, - RoomMods + RoomMods, RoomObject, SessionLog } from '../types/typings'; import { ModificationSaveResult, RackSwitchOption } from '../types/homeTypes'; import { LayoutSaveResult, RackChangeSaveResult } from '../types/layoutEditorTypes'; import { useHomeNavigationContext } from './HomeNavigationContextManager'; import { createNewRoomFromRackChange } from '../api/labkeyActions'; +import { buildUpdatedCageAndRoomMods } from '../utils/homeHelpers'; const RoomContext = createContext({} as RoomContextType); @@ -53,12 +54,32 @@ export const useRoomContext = () => { }; export const RoomContextProvider = ({children}) => { - const {selectedRoom, setSelectedRoom} = useHomeNavigationContext(); + const {selectedLocalRoom, setSelectedLocalRoom} = useHomeNavigationContext(); + const [sessionLog, setSessionLog] = useState({ + startTime: toLabKeyDate(new Date()), + userAgent: navigator.userAgent, + schemaName: 'cageui', + queryName: null, + }); - const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { + const saveRoomObj = (itemId: string, newObj: RoomObject)=> { + setSelectedLocalRoom(prevState => ({ + ...prevState, + objects: prevState.objects.map(obj => { + if(obj.itemId === itemId){ + return newObj; + } + return obj; + }) + })); + } + + + + /*const saveCageMods = (currCage: Cage, currCageMods: CurrCageMods): ModificationSaveResult => { const cageModsByCage: { [key in string]: CageModificationsType } = {}; // string is object uuid let idsToRemove: Map = new Map(); - const newRoomMods: RoomMods = {...selectedRoom.mods}; + const newRoomMods: RoomMods = {...selectedLocalRoom.mods}; // Add adjacent cage mods @@ -153,7 +174,7 @@ export const RoomContextProvider = ({children}) => { } }); - setSelectedRoom( + setSelectedLocalRoom( prevState => ({ ...prevState, rackGroups: prevState.rackGroups.map((g) => ({ @@ -174,10 +195,48 @@ export const RoomContextProvider = ({children}) => { ); return {status: 'Success'}; + };*/ + + const saveCageMods = ( + currCage: Cage, + currCageMods: CurrCageMods + ): ModificationSaveResult => { + // Phase 1: Build updated structures (pure) + const { cageModsByCage, newRoomMods } = + buildUpdatedCageAndRoomMods(selectedLocalRoom, currCage, currCageMods); + + + + // Phase 2: Update React state + setSelectedLocalRoom(prevState => { + // Deep-update cages only where mods were changed + const updatedRackGroups = prevState.rackGroups.map(rg => ({ + ...rg, + racks: rg.racks.map(rack => ({ + ...rack, + cages: rack.cages.map(cage => { + if (cageModsByCage[cage.objectId]) { + return { ...cage, mods: cageModsByCage[cage.objectId] }; + } + return cage; + }), + })), + })); + + return { + ...prevState, + rackGroups: updatedRackGroups, + mods: newRoomMods, + }; + }); + + return { status: 'Success' }; }; + const submitLayoutMods = async (): Promise => { - return saveRoomHelper(selectedRoom); + const newSessionLog: SessionLog = {...sessionLog, queryName: 'cage_modifications_history'}; + return saveRoomHelper(selectedLocalRoom, newSessionLog); }; const submitRackChange = async (newRackOption: RackSwitchOption, prevRack: Rack, prevRackCondition: RackConditionOption): Promise => { @@ -185,13 +244,14 @@ export const RoomContextProvider = ({children}) => { let result: RackChangeSaveResult; let newRoom: Room; let newRack: string; + const newSessionLog: SessionLog = {...sessionLog, queryName: 'rack_history'}; try { - const newRoomRes = await createNewRoomFromRackChange(selectedRoom, newRackOption, prevRack); + const newRoomRes = await createNewRoomFromRackChange(selectedLocalRoom, newRackOption, prevRack); newRoom = newRoomRes.room; let errors; if (newRoomRes.errors) { errors = Array.isArray(newRoomRes.errors) ? newRoomRes.errors : [newRoomRes.errors]; - result = {success: false, roomName: selectedRoom.name, rack: "",reason: errors}; + result = {success: false, roomName: selectedLocalRoom.name, rack: "",reason: errors}; return result; } newRack = newRoomRes.rack; @@ -200,13 +260,13 @@ export const RoomContextProvider = ({children}) => { const errors = Array.isArray(e.errors) ? e.errors : [e.errors]; result = { success: e.success, - roomName: selectedRoom.name, + roomName: selectedLocalRoom.name, rack: "", reason: errors.map(err => err.message || err) }; return result; } - const saveRoomRes = await saveRoomHelper(newRoom,null, prevRackCondition); + const saveRoomRes = await saveRoomHelper(newRoom,newSessionLog, null, prevRackCondition); return { ...saveRoomRes, rack: newRack, @@ -217,7 +277,8 @@ export const RoomContextProvider = ({children}) => { {children} diff --git a/CageUI/src/client/pages/home/RoomHome.tsx b/CageUI/src/client/pages/home/RoomHome.tsx index 372707cda..d99dcf000 100644 --- a/CageUI/src/client/pages/home/RoomHome.tsx +++ b/CageUI/src/client/pages/home/RoomHome.tsx @@ -22,7 +22,7 @@ import '../../cageui.scss'; import { RoomList } from '../../components/home/RoomList'; import { RoomNavbar } from '../../components/home/RoomNavbar'; import { RoomContent } from '../../components/home/RoomContent'; -import { HomeNavigationContextProvider } from '../../context/HomeNavigationContextManager'; +import { HomeNavigationContextProvider, useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; import { RoomContextProvider } from '../../context/RoomContextManager'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; @@ -45,7 +45,7 @@ export const RoomHome: FC = () => { return (user?.container && -
        +
        diff --git a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx index be97c5556..fd0de7825 100644 --- a/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx +++ b/CageUI/src/client/pages/layoutEditor/LayoutEditor.tsx @@ -27,13 +27,13 @@ import Editor from '../../components/layoutEditor/Editor'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { RoomSizeSelector, SelectorOptions } from '../../components/layoutEditor/RoomSizeSelector'; import { ConfirmationPopup } from '../../components/ConfirmationPopup'; -import { isRoomCreator, isTemplateCreator } from '../../utils/LayoutEditorHelpers'; +import { isRoomCreator, isRoomModifier, isTemplateCreator } from '../../utils/helpers'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; import { roomSizeOptions } from '../../utils/constants'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; export const LayoutEditor: FC = () => { - const roomName = ActionURL.getParameter('room'); + const roomName: string = ActionURL.getParameter('room'); const [prevRoomData, setPrevRoomData] = useState({ name: null, cagingData: [], @@ -62,7 +62,10 @@ export const LayoutEditor: FC = () => { // if the user is a template creator grant access if (isTemplateCreator(profile) || (isRoomCreator(profile))) { setAccess(true); - }else{ + }else if(isRoomModifier(profile) && roomName && !roomName.includes("template")){ // ensure room modifiers are editing a real room + setAccess(true); + } + else{ setAccess(false); } } @@ -145,7 +148,10 @@ export const LayoutEditor: FC = () => { } }, [prevRoomData]); - return (!isLoading && userProfile && access) ? ( + return (isLoading) ? +
        +

        Page is loading, please wait.

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

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

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

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

        +
        + ) : ( +
        +

        Error loading page. Please submit a ticket.

        +
        + ); }; \ No newline at end of file diff --git a/CageUI/src/client/types/homeNavigationContextTypes.ts b/CageUI/src/client/types/homeNavigationContextTypes.ts index a2c067177..816a746b8 100644 --- a/CageUI/src/client/types/homeNavigationContextTypes.ts +++ b/CageUI/src/client/types/homeNavigationContextTypes.ts @@ -20,15 +20,19 @@ 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; selectedCage: Cage; navigateTo: (page: SelectedPage) => void; userProfile: GetUserPermissionsResponse; + setIsNavLoading: React.Dispatch>; + isNavLoading: boolean; } \ No newline at end of file diff --git a/CageUI/src/client/types/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/layoutEditorContextTypes.ts b/CageUI/src/client/types/layoutEditorContextTypes.ts index 74661d169..7fb6a245e 100644 --- a/CageUI/src/client/types/layoutEditorContextTypes.ts +++ b/CageUI/src/client/types/layoutEditorContextTypes.ts @@ -70,4 +70,5 @@ export interface LayoutContextType { getAdjCages: (cage: Cage, cageLoc: LocationCoords) => LocationCoords[]; reloadRoom: Room, setReloadRoom: React.Dispatch>, + unmergeRacks: (rackGroup: RackGroup, selectedRack: Rack) => void; } \ No newline at end of file diff --git a/CageUI/src/client/types/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..18076e486 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'; @@ -69,10 +69,13 @@ export enum ModTypes { PCDivider = 'pcd', // protected contact VCDivider = 'vcd', // visual contact PrivacyDivider = 'pd', + LockedDivider = 'ld', NoDivider = 'nd', CTunnel = 'ct', Extension = 'ex', - SPDivider = 'spd' // Social Panel + SPDivider = 'spd', // Social Panel + Restraint = 'res', + Blind = 'bld' } export enum ModDirections { @@ -114,6 +117,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 +130,6 @@ export enum ModSvgLocId { export enum RackConditions { Operational, Damaged, - Repairing, } export type RackStringType = string & { __brand: 'RackStringType' }; @@ -181,6 +185,9 @@ export type Modification = { export type ModRecord = Record; +export interface LoadedSvgs { + [key: RoomItemStringType]: SVGElement; +} export interface FetchRoomData { selectedSize: SelectorOptions; @@ -210,7 +217,7 @@ export interface CageDimensions { } export interface RoomMods { - [key: ModIdKey]: Option; + [key: ModIdKey]: EHRCageMods; } export interface CurrCageMods { @@ -426,4 +433,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..0f8e4fa85 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. @@ -708,7 +830,7 @@ export function checkAdjacent(targetCage: LocationCoords, draggedCage: LocationC } } - return {isAdjacent: false, direction: '0'}; + return {isAdjacent: false, direction: null}; } //Offset for the top left corner of the layout, without doing this objects will randomly jump when dragging and placing @@ -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..b28eef1e5 100644 --- a/CageUI/src/client/utils/constants.ts +++ b/CageUI/src/client/utils/constants.ts @@ -214,6 +214,20 @@ export const Modifications: ModRecord = { value: '4' }] }, + [ModTypes.LockedDivider]: { + name: 'Locked Divider', + svgIds: { + [ModLocations.Left]: LocationWithRotationMap[ModLocations.Left], + [ModLocations.Right]: LocationWithRotationMap[ModLocations.Right], + }, + styles: [{ + property: 'stroke', + value: '#ed1c24' + }, { + property: 'stroke-width', + value: '2' + }] + }, [ModTypes.NoDivider]: { name: 'No Divider', svgIds: { @@ -282,5 +296,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..fff87219e 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -19,6 +19,7 @@ import { AllHistoryData, Cage, + CageDirection, CageModification, CageModificationsType, CageMods, @@ -32,7 +33,7 @@ import { GroupId, GroupRotation, LayoutData, - LayoutHistoryData, + LayoutHistoryData, LoadedSvgs, ModData, ModLocations, ModTypes, @@ -51,6 +52,7 @@ import { RoomObject, RoomObjectStringType, RoomObjectTypes, + SessionLog, TemplateHistoryData, UnitLocations, UnitType @@ -58,12 +60,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, @@ -71,13 +75,39 @@ import { setupEditCageEvent } from './LayoutEditorHelpers'; import { SelectDistinctOptions } from '@labkey/api/dist/labkey/query/SelectDistinctRows'; -import { selectDistinctRows } from '@labkey/components'; +import { selectDistinctRows, selectRows } from '@labkey/components'; import { CELL_SIZE, Modifications, roomSizeOptions, SVG_HEIGHT, SVG_WIDTH } from './constants'; import { ExtraContext, LayoutSaveResult } from '../types/layoutEditorTypes'; import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; 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 +210,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 +462,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`)}`); @@ -444,11 +496,45 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) return prevRoomData; }; +const loadSvgs = async (): Promise => { + const loadedSvgs: LoadedSvgs = {}; + + const config: SelectRowsOptions = { + schemaName: "ehr_lookups", + queryName: "cageui_svg_urls", + columns: ["value", "title"] + } + + const res = await labkeyActionSelectWithPromise(config); + if(res.rowCount > 0){ + + // Create all promises first + const promises = res.rows.map(row => { + return d3.svg(`${ActionURL.getContextPath()}${row.title}`).then((d) => { + if(!loadedSvgs[row.value]){ // cage templates + loadedSvgs[row.value] = d.querySelector(`svg[id*=template]`); + } + if(!loadedSvgs[row.value]){ // room objects + loadedSvgs[row.value] = d.querySelector('svg'); + } + }); + }); + + // Wait for all promises to complete + await Promise.all(promises); + }else{ + console.error("Error finding cageUI Svgs") + } + + return loadedSvgs; +} + // Adds the svgs from the saved layouts to the DOM. Mode edit is version displayed in the layout editor and view is the one in the home views. // roomForMods is passed if the unitsToRender is not room but needs access to the room object. This is for loading mods. -export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { +export const addPrevRoomSvgs = async (user: GetUserPermissionsResponse, mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { let renderType: 'room' | 'group' | 'rack' | 'cage'; + const loadedSvgs: LoadedSvgs = await loadSvgs(); if ((unitsToRender as Room)?.rackGroups) { renderType = 'room'; @@ -502,33 +588,20 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac .style('pointer-events', 'bounding-box'); // This is where the cage svg group is created. - rack.cages.forEach(async (cage) => { + rack.cages.forEach((cage) => { const cageGroup = rackGroup.append('g') .attr('id', cage.svgId) .attr('name', cage.cageNum) .attr('transform', `translate(${cage.x},${cage.y})`); - let unitSvg: SVGElement; - // 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]`); - }); - } - + const unitSvg: SVGElement = loadedSvgs[rackTypeString].cloneNode(true) as SVGElement; // 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 +609,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); + } }); @@ -553,42 +630,40 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac .attr('id', group.groupId) .attr('class', 'draggable rack-group'); - group.racks.forEach(async rack => { + group.racks.forEach( rack => { // Use parent group as rackGroup if only 1 rack, otherwise create a new rack group - await createRackGroup(parentGroup, rack, isSingleRack, group.rotation); + createRackGroup(parentGroup, rack, isSingleRack, group.rotation); }); let groupX = renderType === 'room' ? group.x : group.racks[0].x; let groupY = renderType === 'room' ? group.y : group.racks[0].y; 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); }); - (unitsToRender as Room).objects.forEach(async (roomObj) => { - const roomObjGroup = layoutSvg.append('g') - .data([{x: roomObj.x, y: roomObj.y}]) - .attr('id', roomObj.itemId) + // Render room objects + (unitsToRender as Room).objects.forEach( (roomObj) => { + 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; - } + const objSvg: SVGElement = loadedSvgs[roomItemToString(roomObj.type)].cloneNode(true) as SVGElement; const shape = d3.select(objSvg) .classed('draggable', false) @@ -596,9 +671,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); @@ -610,18 +693,15 @@ export const addPrevRoomSvgs = (mode: 'edit' | 'view', unitsToRender: Room | Rac const cageGroup = layoutSvg.append('g') .attr('id', cage.cageNum) .attr('transform', `translate(0,0)`); - let unitSvg: SVGElement; + const unitSvg: SVGElement = loadedSvgs[parseRoomItemType((unitsToRender as Cage).cageNum)].cloneNode(true) as SVGElement; - d3.svg(`${ActionURL.getContextPath()}/cageui/static/${parseRoomItemType((unitsToRender as Cage).cageNum)}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - const shape = d3.select(unitSvg); - (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; + const shape = d3.select(unitSvg); + (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; - if (mode === 'view') { - loadCageMods(cage, shape, rackGroup.rotation); - } - cageGroup.append(() => shape.node()); - }); + if (mode === 'view') { + loadCageMods(cage, shape, rackGroup.rotation); + } + cageGroup.append(() => shape.node()); } }; @@ -774,8 +854,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) => { @@ -812,7 +891,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit x: rackItem.xCoord - rack.x - group.x, // get cage coords by subtracting from both rack and group y: rackItem.yCoord - rack.y - group.y, size: svgSize, - mods: cageMods + mods: cageMods, }; newUnitLocs[cageNumType].push({ @@ -828,6 +907,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit const rackGroup: RackGroup = findOrAddGroup(rackItem); const rack: Rack = await findOrAddRack(rackGroup, rackItem); await addCageToRack(rack, rackItem, rackGroup); + }; // generates room object state for room objects from layout history data @@ -875,6 +955,55 @@ export const getAdjLocation = (loc: ModLocations): ModLocations => { } }; +export const cageDirectionToModLocation = (loc: CageDirection, rotation: GroupRotation): ModLocations => { + if(rotation === GroupRotation.Origin){ // 0 + switch (loc) { + case CageDirection.Left: + return ModLocations.Left; + case CageDirection.Right: + return ModLocations.Right; + case CageDirection.Top: + return ModLocations.Top; + case CageDirection.Bottom: + return ModLocations.Bottom; + } + }else if(rotation === GroupRotation.Quarter){ // 90 + switch (loc) { + case CageDirection.Left: + return ModLocations.Bottom; + case CageDirection.Right: + return ModLocations.Top; + case CageDirection.Top: + return ModLocations.Right; + case CageDirection.Bottom: + return ModLocations.Left; + } + }else if(rotation === GroupRotation.Half){ // 180 + switch (loc) { + case CageDirection.Left: + return ModLocations.Right; + case CageDirection.Right: + return ModLocations.Left; + case CageDirection.Top: + return ModLocations.Bottom; + case CageDirection.Bottom: + return ModLocations.Top; + } + }else if(rotation === GroupRotation.ThreeQuarter){ // 270 + switch (loc) { + case CageDirection.Left: + return ModLocations.Top; + case CageDirection.Right: + return ModLocations.Bottom; + case CageDirection.Top: + return ModLocations.Left; + case CageDirection.Bottom: + return ModLocations.Right; + } + } + +}; + export const getDefaultMod = (loc: ModLocations): ModTypes | null => { if (loc === ModLocations.Top || loc === ModLocations.Bottom) { return ModTypes.StandardFloor; @@ -1230,7 +1359,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; @@ -1251,43 +1380,66 @@ export const saveRoomHelper = async (room: Room, oldTemplateName?: string, prevR // Create default mods for new rooms. if (isRoomNonDefault) { - const usedMap = new Map(); room.rackGroups.forEach((group) => { group.racks.forEach((r) => { r.cages.forEach((c) => { - if (c.mods === undefined || c.mods === null) { - const connectedCages = findConnectedCages(r, group.rotation, c); - Object.entries(connectedCages).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; + const connectedCages = findConnectedCages(r, group.rotation, c); + const connectedRacks = findConnectedRacks(group, r, c); + + // Combine all potential connection directions from both adjacent cages and racks + const allDirections = new Set([ + ...Object.keys(connectedCages), + ...Object.keys(connectedRacks) + ]); + + allDirections.forEach((direction) => { + const locDir = parseInt(direction) as ModLocations; + const cageConnections = connectedCages[locDir] || []; + const rackConnections = connectedRacks[locDir] || []; + + // Only proceed if there is a connection in this direction + if (cageConnections.length > 0 || rackConnections.length > 0) { + if (c.mods && c.mods[locDir] && c.mods[locDir].length > 0) { + // If existing mods exist for this direction, add them + c.mods[locDir].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: locDir, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, + }); + }); + }); + } else { + // If no mods exist for this connection, add default ones + if (cageConnections.length > 0) { + addModEntries(cageConnections, locDir, r, false, newModData, usedMap); + } + if (rackConnections.length > 0) { + addModEntries(rackConnections, locDir, r, true, newModData, usedMap); + } } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, false, newModData, usedMap); - }); + } + }); - const connectedRacks = findConnectedRacks(group, r, c); - Object.entries(connectedRacks).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; - } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, true, newModData, usedMap); - }); - } else { - Object.entries(c.mods).forEach(([direction, modSubsections]: [string, CageModification[]]) => { - modSubsections.forEach(section => { - section.modKeys.forEach(key => { - newModData.push({ - cage: c.objectId, - location: parseInt(direction), - modId: key.modId, - modification: room.mods[key.modId].value, - parentModId: key.parentModId, - rack: r.objectId, - subId: section.subId, - }); + // Handle Direct location mods (not used in connections) + if (c.mods && c.mods[ModLocations.Direct] && c.mods[ModLocations.Direct].length > 0) { + c.mods[ModLocations.Direct].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: ModLocations.Direct, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, }); }); }); @@ -1300,7 +1452,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 b95e69a09..7aba3f1e5 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; @@ -378,6 +379,7 @@ public static class SaveLayoutHistoryAction extends MutatingApiAction _roomDefaultMods; + private SessionLog _sessionLog; public Room getRoom() { @@ -399,6 +401,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 @@ -412,10 +424,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 { @@ -467,6 +478,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 @@ -502,8 +528,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..c01e49eb8 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 @@ -909,17 +965,12 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund ArrayList templateForms = new ArrayList<>(); // Process rack groups - int rackGroupIndex = 0; for (RackGroup rackGroup : room.getRackGroups()) { - rackGroupIndex++; // Process racks in this group - int rackIndex = 0; for (Rack rack : rackGroup.getRacks()) { - rackIndex++; - // Process cages in this rack if (rack.getCages() != null) { @@ -927,9 +978,9 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund { TemplateLayoutHistoryForm form = new TemplateLayoutHistoryForm(); form.setHistoryId(historyId); - form.setRackGroup(rackGroupIndex); + form.setRackGroup(findLastNumberAfterDash(rackGroup.getGroupId())); form.setGroupRotation(rackGroup.getRotation()); - form.setRack(rackIndex); + form.setRack(rack.getItemId()); form.setCage(findLastNumberAfterDash(cage.getCageNum())); form.setObjectType(rack.getType().getEffectiveRackType().getNumericValue()); form.setExtraContext(cage.getExtraContext() != null ? diff --git a/CageUI/src/org/labkey/cageui/CageUIModule.java b/CageUI/src/org/labkey/cageui/CageUIModule.java index 6bde122a0..0ccbe402a 100644 --- a/CageUI/src/org/labkey/cageui/CageUIModule.java +++ b/CageUI/src/org/labkey/cageui/CageUIModule.java @@ -59,7 +59,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 25.003; + return 26.001; } @Override diff --git a/CageUI/src/org/labkey/cageui/model/ModTypes.java b/CageUI/src/org/labkey/cageui/model/ModTypes.java index 280751f14..6eb02f82a 100644 --- a/CageUI/src/org/labkey/cageui/model/ModTypes.java +++ b/CageUI/src/org/labkey/cageui/model/ModTypes.java @@ -31,10 +31,13 @@ public enum ModTypes PCDivider("pcd"), VCDivider("vcd"), PrivacyDivider("pd"), + LockedDivider("ld"), 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 0bfe318fb..cc8aa4253 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, @NotNull 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 0a7a6323d..d043c11aa 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, @NotNull 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 0ded25056..47b5aa951 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, @NotNull 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 diff --git a/WNPRC_EHR/resources/data/cageui_svg_urls.tsv b/WNPRC_EHR/resources/data/cageui_svg_urls.tsv new file mode 100644 index 000000000..ec3a6157e --- /dev/null +++ b/WNPRC_EHR/resources/data/cageui_svg_urls.tsv @@ -0,0 +1,12 @@ +Value Title Category Description Sort Order Date Disabled +bottom /cageui/static/bottom.svg +cage /cageui/static/cage.svg +door /cageui/static/door.svg +drain /cageui/static/drain.svg +gateClosed /cageui/static/gateClosed.svg +gateOpen /cageui/static/gateOpen.svg +pen /cageui/static/pen.svg +playCage /cageui/static/pen.svg +roomDivider /cageui/static/roomDivider.svg +tempCage /cageui/static/cage.svg +top /cageui/static/top.svg \ No newline at end of file diff --git a/WNPRC_EHR/resources/data/lookup_sets.tsv b/WNPRC_EHR/resources/data/lookup_sets.tsv index 207704850..79aeddff6 100644 --- a/WNPRC_EHR/resources/data/lookup_sets.tsv +++ b/WNPRC_EHR/resources/data/lookup_sets.tsv @@ -14,6 +14,7 @@ blood_billed_by Blood Billed By Field Values value title blood_code_prefixes Blood Code Prefix Field Values value cageui_item_types Room Item Type Field Values value cageui_rack_manufacturers Rack Manufacturer Field Values value +cageui_svg_urls SVG URLS Field Values value chemistry_method Chemistry Method Field Values value chow_types Chow Types Field Values value clinpath_collection_method Clinpath Collection Method Field Values value diff --git a/WNPRC_EHR/resources/views/populateInitialData.html b/WNPRC_EHR/resources/views/populateInitialData.html index db850fad9..561d28778 100644 --- a/WNPRC_EHR/resources/views/populateInitialData.html +++ b/WNPRC_EHR/resources/views/populateInitialData.html @@ -283,6 +283,13 @@ queryName: 'cageui_rack_manufacturers', module: 'wnprc_ehr', pk: 'value' + },{ + label: 'SVG URLS Field Values', + populateFn: 'populateFromFile', + schemaName: 'ehr_lookups', + queryName: 'cageui_svg_urls', + module: 'wnprc_ehr', + pk: 'value' }]; tables.sort(function(a, b) { diff --git a/docker/README.md b/docker/README.md index d71c10987..d32aca3ba 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,64 +1,72 @@ # Creating and Using Docker Images -This folder contains a set of Docker images and a Docker Compose service definition to start and run a LabKey server like the one used at the WNPRC. Each of the subfolders corresponds to a particular service/image used in the Compose definition (e.g., `postgres/` contains configuration information for the PostgreSQL service), and the Gradle build file helps to build the custom images that do not come from any online Docker repository (such as LabKey and our own custom cron service). +This folder contains a set of folders with Dockerfiles and a Compose file which define services to start and run a LabKey server like the one used at the WNPRC. Each of the subfolders corresponds to a particular service/image used in the Compose definition (e.g., `postgres/` contains configuration information for the PostgreSQL service), and the Gradle build file helps to build the custom images that do not come from any online Docker repository (such as LabKey and our own custom cron service). -Any service-specific configuration needs to be defined in a `.env` file in this directory, with a pre-built example file provided in `default.env`. All the variables in the `default.env` file has a prefix to the corresponding service (e.g. LK = labkey, PG = postgres) and they are all organized alphabetically to make it easier to group all variables that affect the different services. Before deploying the services with Compose, you will need to create this `.env` file (e.g., by copying and renaming `default.env`). +Any service-specific configuration needs to be defined in a `.env` file in this directory, with a pre-built example file provided in `default.env`. All the variables in the `default.env` file have a prefix to the corresponding service (e.g. LK = LabKey, PG = postgres) and they are all organized alphabetically to make it easier to group all variables that affect the different services. Before deploying the services with Compose, you will need to create the `.env` file (e.g., by copying and renaming `default.env`). The following files need to be rename to use SSL certificates in your local development machine: `cert.pem.default` and `key.pem.default` both files have to be rename to remove the .default . The names have to match the names in `.env` file. ## Downloading Docker Images from Docker Hub -WNPRC maintains a service contract with Docker Hub. This allows the IDS unit to build images in this cloud service thus not requiring to locally build images in our production server, test environment and developer machines. The contract allows for five accounts to be associated with the WNPRCEHR Organization. The `idsshared` account can be used to download and access our private LabKey images (i.e. [labkeysnapshot](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/general) and [labkey](https://hub.docker.com/repository/docker/wnprcehr/labkey/general)), the token and password for that account can be found in `Keypass-IDS.kdbx` in the `wnprc.dirve.wisc.edu` shared folder. +WNPRC maintains a service contract with Docker Hub. This contract allows the IDS unit to build images in this cloud service thus not requiring to locally build images in our production server, test environment and developer machines. The contract allows for five accounts to be associated with the WNPRCEHR Organization. The `idsshared` account can be used to download and access our private LabKey images (i.e. [labkeysnapshot](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/general) and [labkey](https://hub.docker.com/repository/docker/wnprcehr/labkey/general)), the token and password for that account can be found in `Keypass-IDS.kdbx` in the `wnprc.drive.wisc.edu` shared folder. -Another alternative is to login via the Docker CLI (`docker login`) with the shared username and password. Gradle tasks can login to Docker Hub without the need to type the password. - -All the docker images can be downloaded from Docker Hub using the following commands, user has to be login into Docker Hub. It is best to use a token and/or a password saved on the user's home folder file called `~/.gradle/gradle.properties` by adding the following lines. +Another alternative is to login via the Docker CLI (`docker login`) with the shared username and password. Gradle tasks can login to Docker Hub without the need to type the password but the credentials need to be stored in the gradle.properties. It is best to use a token and/or a password saved on the user's home folder file called `~/.gradle/gradle.properties`, this is the same file used during the LabKey development setup. Add the following lines replacing the data inside brackets. ``` dockerhubUsername=idsshared dockerhubPassword= dockertokenpath= ``` +For a list of all the task use the following commands: + +``` +./gradlew tasks +``` -Gradle tasks to interact with Docker have two versions, one using a [plugin](https://github.com/bmuschko/gradle-docker-plugin) and the second one uses direct command line via the Docker CLI. All the tasks defined in the `build.gradle` file have the two flavors. Either of the following command downloads all the custom images manage by the IDS unit. +Docker images can be downloaded from Docker Hub using the following commands, user has to be logged into Docker Hub as explained in the previous chapter. All Gradle tasks to interact with the Docker engine locally have two versions, one using a [plugin](https://github.com/bmuschko/gradle-docker-plugin) and the second one uses direct command line via the Docker CLI. Thus, all the tasks defined in the `build.gradle` file have two versions. Either of the following commands download all the custom images managed by the IDS unit. ``` ./gradlew downloadAll ./gradlew downloadAllPlug ``` -To download a specific images from a feature branch use the following commands replacing the Labkey version (i.e. XX.YY = 22.11) and the name of the branch inside the brackets: -``` -./gradlew downloadLabkey -PdockerString= -./gradlew dowloadLabkeyPlug -PdockerString= - -./gradlew downloadEhrcron -PdockerString= -./gradlew downloadEhrcronPlug -PdockerString= +To download a specific image from a feature branch use the following commands replacing the Labkey version (i.e. XX.YY = 24.11) and the name of the branch inside the brackets: ``` +./gradlew downloadLabkey -PbranchName= +./gradlew dowloadLabkeyPlug -PbranchName= -For a list of all the task use the follwowing commands: - -``` -./gradlew tasks +./gradlew downloadEhrcron -PbranchName= +./gradlew downloadEhrcronPlug -PbranchName= ``` ## Building the Custom Images To build the custom images from a stand-alone clone, navigate to the **docker** folder (**not** the repository root) and execute the following command: ``` -./gradlew buildAll -PdockerString= +./gradlew buildAll -PbranchName= ``` -From a clone embedded inside a LabKey platform source code, you will need to execute the command from the LabKey root, with the appropriate adjustments to the project path: +From a clone embedded inside a LabKey development setup with all the source code, you will need to execute the command from the LabKey root, with the appropriate adjustments to the project path: ``` -./gradlew :externalModules:wnprc-modules:docker:buildall -PdockerString= +./gradlew :externalModules:wnprc-modules:docker:buildall -PbranchName= ``` -Each of the custom images has its own build task as well (e.g., `buildLabkey`, `buildEhrcron`) and all have corresponding tasks using the pluging (e.g. `buildEhrcronPlug`, `buildPostfixPlug`). The Labkey images depends on a hook (`docker/labkey/hooks/build`) which is used in Docker Hub to correctly interprete GitHub branches naming convencion. This same hook is used by the gradle task to download the correct LabKey installer and create the Docker image. This build tasks does not have a companion option using the plugin. +Each of the custom images has its own build task as well (e.g., `buildLabkey`, `buildEhrcron`) and all have corresponding tasks using the pluging (e.g. `buildEhrcronPlug`, `buildPostfixPlug`). The Labkey and ehrcranrnutils images depend on hooks (`~/hooks/build`) which is used in Docker Hub to correctly interpret GitHub branches naming convencion and build the image for the correct architecture (i.e., arm64 and adm64). This same hook is used by the gradle task to download the correct LabKey installer from TeamCity and create the corresponding Docker image. These build tasks does not have a companion option using the plugin version. + +For newer Apple Silicon all docker images can be built for ARM processors or as multi-platform builds by using the platform argument. Before building a multi-platform version a new builder has to be created. +``` +docker buildx create --name=container +``` +This builder uses an emulator to create images for AMD processor. In the Apple Silicon the build process for AMD images take longer therefore is best to just use arm64 or the default builder. +``` +--platform linux/arm64 +--platform linux/arm64,linux/amd64 +``` Other than using Gradle, the images can each be built directly using Docker by executing a command like this: ``` docker build -t wnprcehr/ehrcron:vX.X.X ehrcron +docker build --builder container --platform linux/arm64 -t wnprcehr/cranrnutils:cranrnutils_YY.MM_featureBranch --load cranrnutils ``` -If changes are only committed to TeamCity or a new based LabKey build needs to be create, use --no-cache option. To build localy, you must obtain the URL to download the installer from TeamCity. The Dockerfile connect to TeamCity using a set of credentials and downloads the LabKey installer. +If changes are only committed to TeamCity or a new based LabKey build needs to be created, use --no-cache option. To build localy, you must obtain the URL to download the installer from TeamCity. The Dockerfile connects to TeamCity using a set of credentials and downloads the LabKey installer. ``` docker build --build-arg LABKEY_TEAMCITY_USERNAME= --build-arg LABKEY_TEAMCITY_PASSWORD= --build-arg TEAMCITY_URL= --build-arg TOMCAT_IMAGE= --build-arg LK_VERSION= --no-cache --rm=true -t wnprcehr/labkey:XX.YY labkey ``` @@ -85,12 +93,16 @@ labkeyTeamcityPassword= To build using Docker directly, you will need to pass those same credentials as run-time build arguments on the command line: ``` -docker build \ - --build-arg LABKEY_TEAMCITY_USERNAME= \ - --build-arg LABKEY_TEAMCITY_PASSWORD= \ - -t wnprcehr/labkey:XX.X labkey +docker build \ +--builder container --platform linux/arm64,linux/amd64 \ +--build-arg LABKEY_TEAMCITY_USERNAME= \ +--build-arg LABKEY_TEAMCITY_PASSWORD= \ +--build-arg TEAMCITY_URL= \ +--build-arg FB_NAME= \ +--build-arg LK_VERSION= --no-cache --rm=true --load \ +-t wnprcehr/labkey:YY.MM_Feature_Branch labkey ``` -If you want to build an images for an specific branch within Github, you should pass one additional argument `--build-arg TOMCAT_IMAGE`. Your commands will look something like this, use the name of the branch without the fb prefix, the name should match as how TeamCity creates the image: +If you want to build an image for a specific branch within Github, you should pass one additional argument `--build-arg TOMCAT_IMAGE`. Your commands will look something like this, use the name of the branch without the fb prefix, the name should match as how TeamCity creates the image: ``` docker build \ --build-arg LABKEY_TEAMCITY_USERNAME= \ @@ -99,22 +111,22 @@ docker build \ -t wnprcehr/labkeyDev:XX.X labkey ``` -The LabKey image depends on the Tomcat image, which can be dowload from Docker Hub or build locally. This image takes a long time to build from scratch, it is best to download it from Docker Hub. Here are the commands to download or build this image. +The LabKey image depends on the Tomcat image, which can be dowloaded from Docker Hub or built locally. This image takes a long time to build from scratch, it is best to download it from Docker Hub. Here are the commands to download or build this image. ``` -./gradlew downloadTomcat -PbranchName= +./gradlew downloadCranr -PbranchName= ./gradlew downloadTomcatPlug -PbranchName= -./gradlew buildTomcat -PbranchName= -./gradlew buildTomcatPlug -PbranchName= +./gradlew buildCranr -PbranchName= +./gradlew buildCranrPlug -PbranchName= docker build --no-cache -t wnprcehr/tomcat:tomcat9_ tomcat ``` ## Deploying the Docker Compose Services -There are several services controlled by the compose.yaml and production.yaml files. Spliting the Docker services in these two files allows to use the same GitHub repository in two different server without having to make changes locally except for changes in the `.env` file. +There are several services controlled by the compose.yaml and production.yaml files. Splitting the Docker services in these two files allows us to use the same GitHub repository in two different servers without having to make changes locally except for changes in the `.env` file. -The Docker services are production EHR, nightly-ehr and test servers ran are the follwoing: +The Docker services ran in production EHR, nightly-ehr and test servers are the following: ||Service|Functionality|YAML File|Repository| |---|---|---|---|---| |1|postgres|database|compose.yaml|[postgres](https://hub.docker.com/_/postgres)| @@ -137,7 +149,7 @@ To deploy the services, you again either use Gradle or use Docker Compose direct # for tearing down all the services ./gradlew :docker:down ``` -To use Docker Compose, you can execute commands like the following (*from this directory*, where your `.env` file is located), this commands will work on production server as well as other servers: +To use Docker Compose, you can execute commands like the following (*from this directory*, where your `.env` file is located), these commands will work on the production server as well as the other servers: ``` # for spinning up all the services in production server docker compose -f compose.yaml -f production.yaml up -d @@ -146,7 +158,7 @@ docker compose -f compose.yaml -f production.yaml up -d docker compose -f compose.yaml -f production.yaml down --timeout 60 ``` -Add `-f compose.yaml -f production.yaml` to make changes in the production server. If this is not added the system will provide a warning that there is an orphan services running. +Add `-f compose.yaml -f production.yaml` to make changes in the production server. If this is not added the system will provide a warning that there are orphan services running. ``` # for spinning up all the services docker compose up -d @@ -160,12 +172,15 @@ docker compose up -d postgres # for taking down just one of the services (e.g., postgres) docker compose stop postgres --timeout 60 +# for removing just one of the services this makes sure the system uses the latest version (e.g., postgres) +docker compose rm postgres --timeout 60 + # for accesing running services to inspect changes use the following commands (e.g., labkey or postgres) docker compose exec labkey /bin/bash ``` All other Docker Compose commands (`logs`, `ps`, etc.) work also. -*Note that sometimes the postgres container closes before the database itself is completely shut down. Be sure to disconnect your pgAdmin and IntelliJ database connections, if any, stop labkey, and then do a shutdown. Otherwise the next time postgres starts it will go into an automatic recovery mode and take a long time to start back up. By adding a timeout of 60 seconds it allows the database to close gracefully and avoid the recovery process. +*Note that sometimes the postgres container closes before the database itself is completely shut down. Be sure to disconnect your pgAdmin and IntelliJ database connections, if any, stop labkey, and then do a shutdown. Otherwise the next time postgres starts it will go into an automatic recovery mode and take a long time to start back up. By adding a timeout of 60 seconds it allows the database to close every connection, shutdown gracefully and avoid the recovery process. ## Docker setup in production EHR Running EHR in the production mode, requires different images for ehrcron and the **SNAPSHOT** version of LabKey. The verison of LabKey is controlled by the following variables stored in `.env` file: `LK_PROD`, `LK_VERSION` and `LK_FB`. These varaibles get replace in the following string `wnprcehr/labkey${LK_PROD}:$LK_VERSION${LK_FB}` during runtime. The string becomes `wnprcehr/labkeysnapshot:24.11` which are the tags in this [repo](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/tags). The version of ehrcron image is also controlled by the `.env` file. The variables `PERL_PROD`, `LK_VERSION` and `LK_FB` are used to modify the name of the image defined in the `production.yaml` file from `wnprcehr/ehrcron$PERL_PROD:$LK_VERSION${LK_FB}` to `wnprcehr/ehrcronprod:24.11`. These are the tags defined in this [repo](https://hub.docker.com/repository/docker/wnprcehr/ehrcronprod/general). The ehrcronprod image has the following scheduled jobs: @@ -176,23 +191,23 @@ Running EHR in the production mode, requires different images for ehrcron and t ## Running multiple instances of LabKey in same Server -We created a folder called `development` in this repo. This folder contains a simplify version of the main `compose.yaml` file. It only has two services: labkey and ngnix. To start a secondary version of labkey in the test server. Copy the development folder, and rename it to particular project. Within the new folder, you have to edit three of files: +We created a folder called `development` in this repo. This folder contains a simplify version of the main `compose.yaml` file. It only has two services: labkey and ngnix. To start a secondary version of labkey in the test server. Copy the development folder, and rename it to particular project. Within the new folder, you have to edit three files: 1. `.env` 1. `nginx/nginx.conf` 1. `compose.yaml` -In the `.env` file, edit the following variables: `LK_DANGER_PORT` to other number than 8080, this is the port which Labkey service will use outside the Docker container. `LK_SECURE_PORT` this port is the one user will need to add to the test server URL to access your instance of LabKey (e.g. https://.primate.wisc.edu:8443). List of ports and databases used for each instance of LabKey in the test-server can be found in this private page: [Test_Servers](https://github.com/WNPRC-EHR-Services/EHR_Documentation/blob/master/sop/Test_Servers.md). Update the list once your instance is up and running. `LK_BASE_URL` to a unique name for your new labkey service, it has to match the name you will modify in the `compose.yaml` file. `PG_NAME` to a database you are planning to use with your new instances of LabKey. +In the `.env` file, edit the following variables: `LK_DANGER_PORT` to a number other than 8080, this is the port which LabKey service will use outside the Docker container. `LK_SECURE_PORT` this port is the one users will need to add to the test server URL to access your instance of LabKey (e.g. https://.primate.wisc.edu:8443). List of ports and databases used for each instance of LabKey in the test-server can be found in this private page: [Test_Servers](https://github.com/WNPRC-EHR-Services/EHR_Documentation/blob/master/sop/Test_Servers.md). Update the list once your instance is up and running. `LK_BASE_URL` to a unique name for your new LabKey service, it has to match the name you will modify in the `compose.yaml` file. `PG_NAME` to a database you are planning to use with your new instances of LabKey. In the `ngnix.conf` file you need to edit the following: `proxy_pass` at the end of the file, to the name you have selected for your new service, it also has to match the name on your `compose.yaml` and `.env` files. -Finally, in your `compose.yaml` file edit the name of the labkey service, it should be unique, therefore check other development folder for all the names used. +Finally, in your `compose.yaml` file edit the name of the LabKey service, it should be unique, therefore check other development folders for all the names used. -All the auxiliary LabKey instances can be manage via the manage_all_continers.sh script. This script accepts two values (-s || -d), s starts all the containers in the docker folder. Starts with the primary which contains postgres and than looks for any folder that starts with dev. +All the auxiliary LabKey instances can be managed via the manage_all_continers.sh script. This script accepts two values (i.e., -s || -d), `-s` - starts all the containers in the docker folder and `-d` - shuts down all the instances running in the server. This script starts with the primary which contains postgres and than looks for any folder that has the prefix dev. ## Loading a Database Backup Using the Script -Along with the Docker-specific utilities in this folder, there is a (Bash-only) script to restore a database backup into a local Docker container: **load_database_backup.sh**. By default, this script will download the latest backup from the production server (assumed to have been created the same day at 1AM) and restore that backup into a PostgreSQL container as defined in the docker-compose.yml and .env files in this folder. Depending on resource on local machine or server, it is possible to increase the number of processors for the restore process. Change the number in line 132 right after -j option, by default is set to 4 processes. +Along with the Docker-specific utilities in this folder, there is a (Bash-only) script to restore a database backup into a local Docker container: **load_database_backup.sh**. By default, this script will download the latest backup from the production server (assumed to have been created the same day at 1AM) and restore that backup into a PostgreSQL container as defined in the docker-compose.yml and .env files in this folder. Depending on the resources on the local machine or server, it is possible to increase the number of processors for the restore process. Change the number in line 132 right after -j option, which by default is set to 4 processes. The script has very few options, as shown in these examples: ```bash @@ -211,6 +226,16 @@ The script has very few options, as shown in these examples: ``` The use of the `-p` flag allows us to use this script to manage multiple instances of the LabKey PostgreSQL container on the same server, provided that each instance is run from its own folder with its own .env file (to specify ports, data file locations, etc.) +## Configuration of nightly-ehr.primate.wisc.edu + +This server is configured to update every night after the production server completes a complete backup and moves the created file to a long term ITSS storage (i.e., `PrimateFS`). The script called `load_database_update_testserver.sh` is based on `load_database_backup.sh` and it is configured to run as a cron job in the `nightly-ehr.primate.wisc.edu` server by the root user. + +To check the current configuraion type: `sudo crontab -l`. To modify the configuration type: `sudo crontab -e`. + +The script uses multiple parameters: `-postgres` - location of postgres executable (i.e., /usr/lib/postgresql/15/bin/), `--dbname` - name of the database to replace, `--jobs` - number of processes to run the backup, `--production` - restore a complete database, `--path` - location of the backup files (~/labkey_backup/database/daily/). + +The script also downloads the latest image of LabKeySnapshot from Docker Hub and cleans all the old images from the local image repository. + ## Additional Configurations In some instance, the shared memory and effective cache size should be modified for dev machines. In the docker/postgres/postgresql.cong file modify line shared_buffers and effective_cache_size to 1024MB and 2048MB respectively. diff --git a/docker/build.gradle b/docker/build.gradle index 0bdd08834..d31ae1a5c 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -98,7 +98,7 @@ tasks.register("buildEhrCronPlug", DockerBuildImage){ group 'DockerPlugin' } -tasks.register("buildTomcat") { Task t -> +tasks.register("buildCranr") { Task t -> doLast { if (!project.hasProperty("nocache")){ ext.nocache = "false" @@ -125,32 +125,32 @@ tasks.register("buildTomcat") { Task t -> if (nocache.equals("true")){ println "building image without cache" t.project.exec{ - workingDir "$projectDir/tomcat" + workingDir "$projectDir/cranrnutils" executable "docker" - args "build", ".", "--no-cache", "-t", "wnprcehr/tomcat:tomcat9_"+branch + args "build", "--platform","linux/arm64,linux/amd64" ,"--no-cache", "-t", "wnprcehr/cranrnutils:cranrnutils_"+branch, "." } }else{ t.project.exec{ - workingDir "$projectDir/tomcat" + workingDir "$projectDir/cranrnutils" executable "docker" - args "build", ".", "-t", "wnprcehr/tomcat:tomcat9_"+branch + args "build", "--platform","linux/arm64,linux/amd64" ,"-t", "wnprcehr/cranrnutils:cranrnutils_"+branch, "." } } }else { - logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildTomcat -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildCranr -PbranchName=22.11_fb)' } } } -configure(buildTomcat){ +configure(buildCranr){ group 'Docker' description 'Generates the Tomcat docker image (with R)' dependsOn "checkDocker", "dockerLogin", "processbranchName" onlyIf {buildType != ''} } -tasks.register("buildTomcatPlug", DockerBuildImage){ +tasks.register("buildCranrPlug", DockerBuildImage){ def dockerStr = providers.gradleProperty("branchName") if (dockerStr.present){ // String branch = "" @@ -169,13 +169,13 @@ tasks.register("buildTomcatPlug", DockerBuildImage){ break; } inputDir.set(file("tomcat/")) - images.add("wnprcehr/tomcat:tomcat9_${branch}") + images.add("wnprcehr/cranrnutils:cranrnutils_${branch}") }else { - logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildTomcatPlug -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildCranrPlug -PbranchName=22.11_fb)' } } -configure(buildTomcatPlug){ +configure(buildCranrPlug){ group 'DockerPlugin' description 'Generates the Tomcat docker image (with R)' dependsOn "checkDocker", "dockerLogin", "processbranchName" @@ -184,7 +184,7 @@ configure(buildTomcatPlug){ //Building LabKey with hooks/build for consistancy with Docker Hub and used of environment variables duirng the build process //Source: https://stackoverflow.com/questions/36322536/how-to-set-an-environment-variable-from-a-gradle-build / https://stackoverflow.com/a/63140816 tasks.register("buildLabkey") { Task t -> -//task("buildLabkey", group: "Docker", description: "Generates the LabKey docker image", dependsOn: ["buildTomcat", "buildEhrCron", "buildPostfix"]) { Task t -> +//task("buildLabkey", group: "Docker", description: "Generates the LabKey docker image", dependsOn: ["buildCranr", "buildEhrCron", "buildPostfix"]) { Task t -> doLast { def dockerStr = providers.gradleProperty("branchName") @@ -317,7 +317,7 @@ configure(tagPostfixPlug){ tasks.register("buildAll"){ group 'Docker' description 'Generates all the docker images in the subfolders' - dependsOn "buildTomcat", "buildEhrcron", "buildLabkey", "buildPostfix" + dependsOn "buildCranr", "buildEhrcron", "buildLabkey", "buildPostfix" } @@ -445,7 +445,7 @@ tasks.register("downloadPostfixPlug",DockerPullImage){ dependsOn 'checkDocker' } -tasks.register("downloadTomcat"){ +tasks.register("downloadCranr"){ doLast { def dockerStr = providers.gradleProperty("branchName") if (dockerStr.present) @@ -454,11 +454,11 @@ tasks.register("downloadTomcat"){ String branchName = dockerStr.get() t.project.exec { executable "docker" - args "pull", "wnprcehr/tomcat:tomcat9_${branchName}" + args "pull", "wnprcehr/cranrnutils:cranrnutils_${branch}" } } else{ - logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadTomcat -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadCranr -PbranchName=22.11_fb)' } } group 'Docker' @@ -467,8 +467,8 @@ tasks.register("downloadTomcat"){ } -tasks.register("downloadTomcatPlug",DockerPullImage){ - image = "wnprcehr/tomcat:tomcat9_${branchName}" +tasks.register("downloadCranrPlug",DockerPullImage){ + image = "wnprcehr/cranrnutils:cranrnutils_${branch}" onComplete{ def dockerStr = providers.gradleProperty("branchName") @@ -478,7 +478,7 @@ tasks.register("downloadTomcatPlug",DockerPullImage){ } else{ - logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadTomcatPlug -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadCranrPlug -PbranchName=22.11_fb)' } } doLast{ diff --git a/docker/cranrnutils/Dockerfile b/docker/cranrnutils/Dockerfile index b1ab16e88..82fdb2460 100644 --- a/docker/cranrnutils/Dockerfile +++ b/docker/cranrnutils/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17-jre-jammy +FROM eclipse-temurin:25-jre-noble # Set noninteractive mode for apt-get ENV DEBIAN_FRONTEND=noninteractive @@ -7,37 +7,35 @@ ENV LANG=en_US.utf8 RUN apt-get update \ && apt-get -qq install -y lsb-release \ - software-properties-common \ + software-properties-common \ + curl \ && apt-get clean -# Download and add siging key for Postgres 15 -# https://www.linuxtechi.com/how-to-install-postgresql-on-ubuntu/ -# https://askubuntu.com/a/1456015 -RUN echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list -RUN wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | tee /etc/apt/trusted.gpg.d/pgdg.asc > /dev/null 2>&1 +# Download and add signing key for Postgres 15 +RUN mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/keyrings/pgdg.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list # Download and add signing key for CRAN repository -# https://www.r-bloggers.com/2022/08/installation-of-r-4-2-on-ubuntu-22-04-1-lts-and-tips-for-spatial-packages/ -# https://phoenixnap.com/kb/install-r-ubuntu -RUN wget -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc | tee -a /etc/apt/trusted.gpg.d/cran_ubuntu_key.asc > /dev/null 2>&1 -RUN add-apt-repository "deb https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" +RUN curl -fsSL https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc -o /etc/apt/keyrings/cran_ubuntu_key.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/cran_ubuntu_key.asc] https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" > /etc/apt/sources.list.d/cran.list # Adding various application to download packages to install Postgres 15 and CRAN-R RUN apt-get update \ && apt-get -qq install -y texinfo \ - tzdata \ - gettext \ - make \ - openssl \ - #Linux packages needed for installing CRAN-R - dirmngr \ - #Linux packages needed for RlabKey - libcurl4-openssl-dev \ - libssl-dev \ - postgresql-client-15 \ - r-base \ - r-base-dev \ + tzdata \ + gettext \ + make \ + openssl \ + #Linux packages needed for installing CRAN-R + dirmngr \ + #Linux packages needed for RlabKey + libcurl4-openssl-dev \ + libssl-dev \ + postgresql-client-15 \ + r-base \ + r-base-dev \ && apt-get clean # install the necessary R packages diff --git a/docker/gradle/wrapper/gradle-wrapper.jar b/docker/gradle/wrapper/gradle-wrapper.jar index 249e5832f..61285a659 100644 Binary files a/docker/gradle/wrapper/gradle-wrapper.jar and b/docker/gradle/wrapper/gradle-wrapper.jar differ diff --git a/docker/gradle/wrapper/gradle-wrapper.properties b/docker/gradle/wrapper/gradle-wrapper.properties index ae04661ee..2f2958b92 100644 --- a/docker/gradle/wrapper/gradle-wrapper.properties +++ b/docker/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/docker/labkey/Dockerfile b/docker/labkey/Dockerfile index eea97b456..bdf136625 100644 --- a/docker/labkey/Dockerfile +++ b/docker/labkey/Dockerfile @@ -22,7 +22,7 @@ RUN echo -e "Downloading LabKey build from \033[1;33m${Z}\033[0m" \ # ----------------------------------------------------------------------------- # MAIN IMAGE BUILD DEFINITION -FROM --platform=$BUILDPLATFORM wnprcehr/cranrnutils:cranrnutils_${LK_VERSION}${FB_NAME} +FROM wnprcehr/cranrnutils:cranrnutils_${LK_VERSION}${FB_NAME} # creating folders for LabKey installation RUN mkdir -p /labkey/labkey diff --git a/wnprc_billing/resources/queries/wnprc_billing/perDiemFeeRates.sql b/wnprc_billing/resources/queries/wnprc_billing/perDiemFeeRates.sql index 1bd9b2a2d..a25f4696a 100644 --- a/wnprc_billing/resources/queries/wnprc_billing/perDiemFeeRates.sql +++ b/wnprc_billing/resources/queries/wnprc_billing/perDiemFeeRates.sql @@ -58,4 +58,5 @@ (CAST(pdt.adate AS DATE) <= cr1.enddate OR cr1.enddate IS NULL)) LEFT JOIN ehr_billing.chargeableItems ci1 ON ci1.rowid = cr1.chargeId) pdr WHERE (pdr.item = 'Per diems' AND pdr.project.projectType IS NULL) --Excluding research projects from reduce perDiem - OR (pdr.item = 'Special Animal Per Diem' AND pdr.project.projectType = 'Marmoset U24') --Only assigning reduce perDiem to U24 projects \ No newline at end of file + OR (pdr.item = 'Special Animal Per Diem' AND pdr.project.projectType = 'Marmoset U24') --Only assigning reduce perDiem to U24 projects + OR (pdr.item = 'BSL3 Per diems' AND pdr.project.projectType = 'BSL3') \ No newline at end of file diff --git a/wnprc_billing/resources/schemas/dbscripts/postgresql/wnprc_billing-22.000-22.001.sql b/wnprc_billing/resources/schemas/dbscripts/postgresql/wnprc_billing-22.000-22.001.sql new file mode 100644 index 000000000..16053660f --- /dev/null +++ b/wnprc_billing/resources/schemas/dbscripts/postgresql/wnprc_billing-22.000-22.001.sql @@ -0,0 +1,2 @@ +-- Dropping idx_wnprc_billing_groupcategoryassociations_chargegroupname [chargeGroupName] because it overlaps with uq_wnprc_billing_groupcategoryassociations_group_category [chargeGroupName, chargeCategoryId] +DROP INDEX wnprc_billing.idx_wnprc_billing_groupcategoryassociations_chargegroupname; diff --git a/wnprc_billing/src/org/labkey/wnprc_billing/WNPRC_BillingModule.java b/wnprc_billing/src/org/labkey/wnprc_billing/WNPRC_BillingModule.java index ac1ad4461..962a2e74e 100644 --- a/wnprc_billing/src/org/labkey/wnprc_billing/WNPRC_BillingModule.java +++ b/wnprc_billing/src/org/labkey/wnprc_billing/WNPRC_BillingModule.java @@ -65,7 +65,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 22.000; + return 22.001; } @Override diff --git a/wnprc_billing/src/org/labkey/wnprc_billing/table/WNPRC_BillingCustomizer.java b/wnprc_billing/src/org/labkey/wnprc_billing/table/WNPRC_BillingCustomizer.java index e19e0c290..f19830977 100644 --- a/wnprc_billing/src/org/labkey/wnprc_billing/table/WNPRC_BillingCustomizer.java +++ b/wnprc_billing/src/org/labkey/wnprc_billing/table/WNPRC_BillingCustomizer.java @@ -80,6 +80,8 @@ private void customizeChargeableItems(AbstractTableInfo ti) " THEN 'Yes'" + " WHEN " + ExprColumn.STR_TABLE_ALIAS + ".name = 'Special Animal Per Diem'" + " THEN 'Yes'" + + " WHEN " + ExprColumn.STR_TABLE_ALIAS + ".name = 'BSL3 Per diems'" + + " THEN 'Yes'" + " ELSE ''" + " END)" + ")";