diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index aad83b00f8..a5f37223ba 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -1,243 +1,650 @@ - diff --git a/src/components/Editor/Resources/ResourceListItem.vue b/src/components/Editor/Resources/ResourceListItem.vue deleted file mode 100644 index 2b5cf8d24a..0000000000 --- a/src/components/Editor/Resources/ResourceListItem.vue +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - - diff --git a/src/components/Editor/Resources/ResourceListSearch.vue b/src/components/Editor/Resources/ResourceListSearch.vue deleted file mode 100644 index 74cf801abe..0000000000 --- a/src/components/Editor/Resources/ResourceListSearch.vue +++ /dev/null @@ -1,294 +0,0 @@ - - - - - - - diff --git a/src/components/Editor/Resources/ResourceRoomCard.vue b/src/components/Editor/Resources/ResourceRoomCard.vue new file mode 100644 index 0000000000..ca484ec4b8 --- /dev/null +++ b/src/components/Editor/Resources/ResourceRoomCard.vue @@ -0,0 +1,203 @@ + + + + + + + diff --git a/src/models/principal.js b/src/models/principal.js index 1e50eaeed5..085fe17565 100644 --- a/src/models/principal.js +++ b/src/models/principal.js @@ -50,6 +50,14 @@ function getDefaultPrincipalObject(props) { principalId: null, // The url of the default calendar for invitations scheduleDefaultCalendarUrl: null, + // Room-specific properties (only for calendar-rooms) + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, ...props, } } @@ -91,6 +99,33 @@ function mapDavToPrincipal(dav) { const url = dav.principalUrl const userId = dav.userId + // Extract room-specific properties from DAV object using standard cdav-library getters + const roomSeatingCapacity = dav.roomSeatingCapacity ?? null + const roomType = dav.roomType ?? null + const roomFeatures = dav.roomFeatures ?? null + const roomBuildingAddress = dav.roomBuildingAddress ?? null + // Derive building name from address (everything before first comma): "Poppodium, Kerkstraat 10" → "Poppodium" + const roomBuildingName = roomBuildingAddress ? roomBuildingAddress.split(',')[0].trim() : null + // Room number (floor.room format, e.g. "2.17") is stored in room-building-room-number + const roomNumber = dav.roomBuildingRoomNumber ?? null + + // Construct roomAddress for event LOCATION field from available properties + // Format: "Street (Building, Room X.XX)" — street-first for map/navigation apps + let roomAddress = null + if (roomBuildingAddress) { + const commaIdx = roomBuildingAddress.indexOf(',') + if (commaIdx > 0) { + const building = roomBuildingAddress.substring(0, commaIdx).trim() + const street = roomBuildingAddress.substring(commaIdx + 1).trim() + const detail = roomNumber ? building + ', Room ' + roomNumber : building + roomAddress = street + ' (' + detail + ')' + } else { + roomAddress = roomNumber + ? roomBuildingAddress + ' (Room ' + roomNumber + ')' + : roomBuildingAddress + } + } + return getDefaultPrincipalObject({ id, calendarUserType, @@ -107,6 +142,13 @@ function mapDavToPrincipal(dav) { principalId, userId, scheduleDefaultCalendarUrl, + roomSeatingCapacity, + roomType, + roomAddress, + roomFeatures, + roomBuildingName, + roomBuildingAddress, + roomNumber, }) } diff --git a/src/models/resourceProps.js b/src/models/resourceProps.js index 4e11b46b1a..1c72e46360 100644 --- a/src/models/resourceProps.js +++ b/src/models/resourceProps.js @@ -13,7 +13,12 @@ import { translate as t } from '@nextcloud/l10n' export function getAllRoomTypes() { return [ { value: 'meeting-room', label: t('calendar', 'Meeting room') }, + { value: 'board-room', label: t('calendar', 'Board room') }, + { value: 'conference-room', label: t('calendar', 'Conference room') }, { value: 'lecture-hall', label: t('calendar', 'Lecture hall') }, + { value: 'rehearsal-room', label: t('calendar', 'Rehearsal room') }, + { value: 'studio', label: t('calendar', 'Studio') }, + { value: 'outdoor-area', label: t('calendar', 'Outdoor area') }, { value: 'seminar-room', label: t('calendar', 'Seminar room') }, { value: 'other', label: t('calendar', 'Other') }, ] @@ -29,3 +34,35 @@ export function formatRoomType(value) { const option = getAllRoomTypes().find((option) => option.value === value) return option?.label ?? null } + +/** + * Short labels for known facility types. + * Evaluated lazily (inside a function) so that t() is not called at module-import + * time, which would break test mocks. + * + * @return {object} + */ +function getFacilityLabels() { + return { + projector: t('calendar', 'Projector'), + beamer: t('calendar', 'Projector'), + whiteboard: t('calendar', 'Whiteboard'), + video_conference: t('calendar', 'Video'), + videoconference: t('calendar', 'Video'), + wheelchair_accessible: t('calendar', 'Wheelchair accessible'), + 'wheelchair-accessible': t('calendar', 'Wheelchair accessible'), + audio: t('calendar', 'Audio'), + display: t('calendar', 'Display'), + } +} + +/** + * Get a human-readable label for a facility + * + * @param {string} facility The facility identifier + * @return {string} + */ +export function formatFacility(facility) { + const lower = facility.toLowerCase().trim() + return getFacilityLabels()[lower] || facility.charAt(0).toUpperCase() + facility.slice(1) +} diff --git a/tests/javascript/unit/models/principal.test.js b/tests/javascript/unit/models/principal.test.js index 5bcdb92750..a794a9e7b6 100644 --- a/tests/javascript/unit/models/principal.test.js +++ b/tests/javascript/unit/models/principal.test.js @@ -24,6 +24,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: null, scheduleDefaultCalendarUrl: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -48,6 +55,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { principalId: 'bar', otherProp: 'foo', scheduleDefaultCalendarUrl: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -82,6 +96,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'jane.doe', userId: 'legacy-jane-doe-uid', + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -116,6 +137,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'jane.doe', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -150,6 +178,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'CGAH82BAS285H', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -184,6 +219,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: 'projector-123', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) @@ -218,6 +260,59 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: true, principalId: 'room-123', userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, + }) + }) + + it('should properly map a calendar-room-principal with room properties', () => { + const dav = { + addressBookHomes: undefined, + calendarHomes: [], + calendarUserAddressSet: [], + calendarUserType: 'ROOM', + displayname: 'Conference Room A', + email: 'conf-a@example.com', + principalScheme: 'principal:principals/calendar-rooms/conf-a', + principalUrl: '/remote.php/dav/principals/calendar-rooms/conf-a/', + scheduleInbox: null, + scheduleOutbox: null, + url: '/remote.php/dav/principals/calendar-rooms/conf-a/', + userId: null, + roomSeatingCapacity: 20, + roomType: 'conference-room', + roomFeatures: 'PROJECTOR,WHITEBOARD', + roomBuildingAddress: 'Building A, Main Street 1', + roomBuildingRoomNumber: '2.17', + } + + expect(mapDavToPrincipal(dav)).toEqual({ + id: 'L3JlbW90ZS5waHAvZGF2L3ByaW5jaXBhbHMvY2FsZW5kYXItcm9vbXMvY29uZi1hLw==', + dav, + calendarUserType: 'ROOM', + principalScheme: 'principal:principals/calendar-rooms/conf-a', + emailAddress: 'conf-a@example.com', + displayname: 'Conference Room A', + url: '/remote.php/dav/principals/calendar-rooms/conf-a/', + isUser: false, + isGroup: false, + isCircle: false, + isCalendarResource: false, + isCalendarRoom: true, + principalId: 'conf-a', + userId: null, + roomSeatingCapacity: 20, + roomType: 'conference-room', + roomFeatures: 'PROJECTOR,WHITEBOARD', + roomBuildingName: 'Building A', + roomBuildingAddress: 'Building A, Main Street 1', + roomNumber: '2.17', + roomAddress: 'Main Street 1 (Building A, Room 2.17)', }) }) @@ -252,6 +347,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: false, principalId: null, userId: null, + roomSeatingCapacity: null, + roomType: null, + roomAddress: null, + roomFeatures: null, + roomBuildingName: null, + roomBuildingAddress: null, + roomNumber: null, }) }) }) diff --git a/vitest.config.js b/vitest.config.js index c4c6487392..9703ea6894 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,17 +1,17 @@ /** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [vue()], resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) + '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, test: { @@ -24,8 +24,13 @@ export default defineConfig({ environment: 'jsdom', // Required for transforming CSS files pool: 'vmForks', + poolOptions: { + vmForks: { + singleFork: true, + }, + }, // Increase timeouts for slow CI environments testTimeout: 300000, // 2 minutes per test - hookTimeout: 60000, // 60 seconds for hooks + hookTimeout: 60000, // 60 seconds for hooks }, -}); +})