From a66d94fb06d66a58d0eb7728c7047e11dd79151f Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Tue, 5 May 2026 22:44:57 +0200 Subject: [PATCH 1/2] feat(principal): map CalDAV room metadata properties Extend the principal model to extract room-seating-capacity, room-type, room-features, room-building-address, and room-building-room-number from CalDAV principal responses. These properties are defined in the CalDAV standard and already served by Nextcloud room backends, but not yet used by the Calendar frontend. Mapping them into the principal model makes them available for any future UI improvement (e.g. a browsable room finder) without changing how principals are fetched. Backward compatible: properties default to null when not provided by the backend. Also derives roomBuildingName from the building address (first segment) and constructs a roomAddress string suitable for the event LOCATION field. Signed-off-by: Rik Dekker --- src/models/principal.js | 47 +++++++ .../javascript/unit/models/principal.test.js | 130 ++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/src/models/principal.js b/src/models/principal.js index 1e50eaeed5..fe4cddd8aa 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,38 @@ function mapDavToPrincipal(dav) { const url = dav.principalUrl const userId = dav.userId + // Extract room-specific properties from DAV object, trimming string values defensively + const roomSeatingCapacity = dav.roomSeatingCapacity ?? null + const roomType = (dav.roomType ?? '').toString().trim() || null + const roomFeatures = (dav.roomFeatures ?? '').toString().trim() || null + // Strip leading/trailing whitespace and commas from building address to handle empty + // building-name fields, e.g. ", Science Park 140, 1098 XG, Amsterdam" → "Science Park 140, 1098 XG, Amsterdam" + const rawBuildingAddress = dav.roomBuildingAddress ?? null + const roomBuildingAddress = rawBuildingAddress + ? rawBuildingAddress.replace(/^[\s,]+|[\s,]+$/g, '').trim() || null + : null + // Derive building name from address (everything before first comma): "Poppodium, Kerkstraat 10" → "Poppodium" + const roomBuildingName = roomBuildingAddress ? roomBuildingAddress.split(',')[0].trim() || null : null + // Room number (floor.room format, e.g. "2.17") is stored in room-building-room-number + const roomNumber = (dav.roomBuildingRoomNumber ?? '').toString().trim() || 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 +147,13 @@ function mapDavToPrincipal(dav) { principalId, userId, scheduleDefaultCalendarUrl, + roomSeatingCapacity, + roomType, + roomAddress, + roomFeatures, + roomBuildingName, + roomBuildingAddress, + roomNumber, }) } diff --git a/tests/javascript/unit/models/principal.test.js b/tests/javascript/unit/models/principal.test.js index 5bcdb92750..e8712a2921 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,9 +260,90 @@ 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)', + }) + }) + + it('should strip leading commas and whitespace from roomBuildingAddress', () => { + const dav = { + addressBookHomes: undefined, + calendarHomes: [], + calendarUserAddressSet: [], + calendarUserType: 'ROOM', + displayname: 'AMS 0.11', + email: 'ams-011@example.com', + principalScheme: 'principal:principals/calendar-rooms/ams-011', + principalUrl: '/remote.php/dav/principals/calendar-rooms/ams-011/', + scheduleInbox: null, + scheduleOutbox: null, + url: '/remote.php/dav/principals/calendar-rooms/ams-011/', + userId: null, + roomSeatingCapacity: 1, + roomType: 'meeting-room', + roomFeatures: ' ', + roomBuildingAddress: ', Science Park 140, 1098 XG, Amsterdam', + roomBuildingRoomNumber: '0.11', + } + + const result = mapDavToPrincipal(dav) + expect(result.roomBuildingAddress).toBe('Science Park 140, 1098 XG, Amsterdam') + expect(result.roomBuildingName).toBe('Science Park 140') + expect(result.roomFeatures).toBe(null) + expect(result.roomAddress).toBe('1098 XG, Amsterdam (Science Park 140, Room 0.11)') + }) + it('should properly map a principal from an unknown backend to principal-object', () => { const dav = { addressBookHomes: undefined, @@ -252,6 +375,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, }) }) }) From 3edf2ae84afd623724bd261287ebbc23a7cc5570 Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Tue, 5 May 2026 22:46:09 +0200 Subject: [PATCH 2/2] feat(resources): browsable room finder with availability and dropdown filters Replace the search-based resource picker with a browsable room finder that loads all room principals on mount and shows their availability in real time via free/busy queries. UI follows the design feedback from @jancborchardt on PR #7996: right-column placement (Outlook-style), NcSelect dropdowns for Building/Capacity/Floor/Features (no chips), text search, and a "Show unavailable" toggle. Each room is rendered as a compact card with availability status, capacity, and add/remove action. Selecting a room auto-fills the event LOCATION property using the roomAddress derived from CalDAV building-address and room-number metadata. Implementation reuses existing services and components: - principalsStore.getRoomPrincipals for initial load - checkResourceAvailability() from freeBusyService.js for availability - @nextcloud/vue: NcSelect, NcTextField, NcCheckboxRadioSwitch, NcLoadingIcon, NcButton Removes the now-redundant ResourceListItem.vue and ResourceListSearch.vue. Adds formatFacility() helper and extends getAllRoomTypes() in resourceProps.js with additional standard room types (board room, conference room, rehearsal room, studio, outdoor area). Components are written in TypeScript with Composition API and 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..3513741a91 --- /dev/null +++ b/src/components/Editor/Resources/ResourceRoomCard.vue @@ -0,0 +1,205 @@ + + + + + + + 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/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 }, -}); +})