From 7aa72e6b3b833416f86dcaedc5e005c828a8c4a2 Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Thu, 19 Feb 2026 17:24:19 +0100 Subject: [PATCH 01/12] feat(resources): add visual room browser with building grouping and filters Replace the minimal resource picker with a comprehensive room browser that loads all room principals upfront and provides real-time availability checking, client-side filtering, and building-based grouping. Changes: - ResourceList.vue: full rewrite with search, available-only toggle, min capacity filter, dynamic building and facility chips, collapsible building groups, and room cards with availability status - ResourceRoomCard.vue: new component showing room name, availability badge, capacity, floor, room type, and add/remove action - principal.js: extract room-specific DAV properties (seating capacity, room type, features, building address, room number) and derive a structured location string for the LOCATION field - resourceProps.js: add formatFacility() helper and extend getAllRoomTypes() with additional room types Signed-off-by: Rik Dekker --- .../Editor/Resources/ResourceList.vue | 619 ++++++++++++++---- .../Editor/Resources/ResourceRoomCard.vue | 194 ++++++ src/models/principal.js | 42 ++ src/models/resourceProps.js | 31 + 4 files changed, 773 insertions(+), 113 deletions(-) create mode 100644 src/components/Editor/Resources/ResourceRoomCard.vue diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index aad83b00f8..8a40c833bf 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -4,45 +4,140 @@ --> diff --git a/src/components/Editor/Resources/ResourceRoomCard.vue b/src/components/Editor/Resources/ResourceRoomCard.vue new file mode 100644 index 0000000000..654fd920fa --- /dev/null +++ b/src/components/Editor/Resources/ResourceRoomCard.vue @@ -0,0 +1,194 @@ + + + + + + + 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..a2ec8b2c9b 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,29 @@ export function formatRoomType(value) { const option = getAllRoomTypes().find((option) => option.value === value) return option?.label ?? null } + +/** + * Short labels for known facility types + */ +const FACILITY_LABELS = { + 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 FACILITY_LABELS[lower] || facility.charAt(0).toUpperCase() + facility.slice(1) +} From 95d99a62c161b0b73094e19ff01b2382eee17942 Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Thu, 19 Feb 2026 17:31:47 +0100 Subject: [PATCH 02/12] fix(resources): use logical CSS properties for RTL support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace directional CSS properties with logical equivalents: - margin-right → margin-inline-end - text-align: left → text-align: start - padding-left → padding-inline-start - border-left → border-inline-start Signed-off-by: Rik Dekker --- src/components/Editor/Resources/ResourceList.vue | 6 +++--- src/components/Editor/Resources/ResourceRoomCard.vue | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 8a40c833bf..390e9e8b6d 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -533,7 +533,7 @@ export default { display: flex; align-items: center; color: var(--color-text-maxcontrast); - margin-right: 2px; + margin-inline-end: 2px; } &__chip { @@ -602,7 +602,7 @@ export default { &-name { flex: 1; - text-align: left; + text-align: start; } &-count { @@ -615,7 +615,7 @@ export default { display: flex; flex-direction: column; gap: calc(var(--default-grid-baseline) * 1); - padding-left: 8px; + padding-inline-start: 8px; padding-bottom: 4px; } } diff --git a/src/components/Editor/Resources/ResourceRoomCard.vue b/src/components/Editor/Resources/ResourceRoomCard.vue index 654fd920fa..cc1efe2131 100644 --- a/src/components/Editor/Resources/ResourceRoomCard.vue +++ b/src/components/Editor/Resources/ResourceRoomCard.vue @@ -135,7 +135,7 @@ export default { background: var(--color-main-background); &--added { - border-left: 3px solid var(--color-primary); + border-inline-start: 3px solid var(--color-primary); background: var(--color-primary-element-light); } From cb44e4635bb836a6a94f4ce45b431e1be1a385ff Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Thu, 19 Feb 2026 17:37:45 +0100 Subject: [PATCH 03/12] test(principal): update tests for room-specific properties Add room property fields (roomSeatingCapacity, roomType, roomAddress, roomFeatures, roomBuildingName, roomBuildingAddress, roomNumber) to all existing principal test expectations. Add a new test case for a room principal with actual room DAV properties to verify the building name derivation and address formatting logic. Signed-off-by: Rik Dekker --- .../javascript/unit/models/principal.test.js | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) 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, }) }) }) From f92802f4afcb028c8af7deecdad2ece99bfb7034 Mon Sep 17 00:00:00 2001 From: Rik Dekker Date: Thu, 19 Feb 2026 17:44:51 +0100 Subject: [PATCH 04/12] fix(resources): address ESLint errors in ResourceList and ResourceRoomCard ResourceList.vue: - Replace deprecated :value with v-model on NcTextField - Use camelCase for component attributes and event names - Fix import ordering (perfectionist/sort-imports) - Add curly braces to all if/else blocks - Replace deprecated Vue.set with direct property assignment - Split multi-statement forEach bodies to separate lines - Remove self-closing slash from void element - Fix function-paren-newline style ResourceRoomCard.vue: - Replace deprecated :type with :variant on NcButton - Add emits option declaring removeRoom and addRoom - Use camelCase for emitted event names - Add required newlines between multi-line props Signed-off-by: Rik Dekker --- .../Editor/Resources/ResourceList.vue | 69 ++++++++++--------- .../Editor/Resources/ResourceRoomCard.vue | 19 +++-- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 390e9e8b6d..5d30a8a6fc 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -15,12 +15,11 @@ v-if="!isReadOnly && hasUserEmailAddress && resourceBookingEnabled" class="resource-picker__filters"> + :showTrailingButton="filterText.length > 0" + trailingButtonIcon="close" + @trailingButtonClick="filterText = ''" />
+ class="resource-picker__capacity-input">
@@ -101,12 +100,12 @@ v-for="room in group.rooms" :key="room.id" :room="room" - :is-added="isRoomAdded(room)" - :is-read-only="isReadOnly" - :is-viewed-by-organizer="isViewedByOrganizer" - :has-room-selected="resources.length > 0" - @add-room="addResource" - @remove-room="removeRoomByPrincipal" /> + :isAdded="isRoomAdded(room)" + :isReadOnly="isReadOnly" + :isViewedByOrganizer="isViewedByOrganizer" + :hasRoomSelected="resources.length > 0" + @addRoom="addResource" + @removeRoom="removeRoomByPrincipal" /> @@ -130,12 +129,12 @@ import { NcCheckboxRadioSwitch, NcLoadingIcon } from '@nextcloud/vue' import NcTextField from '@nextcloud/vue/components/NcTextField' import debounce from 'debounce' import { mapStores } from 'pinia' -import Vue from 'vue' -import MapMarker from 'vue-material-design-icons/MapMarker.vue' import ChevronDown from 'vue-material-design-icons/ChevronDown.vue' import ChevronRight from 'vue-material-design-icons/ChevronRight.vue' +import MapMarker from 'vue-material-design-icons/MapMarker.vue' import OfficeBuildingOutline from 'vue-material-design-icons/OfficeBuildingOutline.vue' import Wrench from 'vue-material-design-icons/Wrench.vue' + import ResourceRoomCard from './ResourceRoomCard.vue' import { formatFacility } from '../../../models/resourceProps.js' import { checkResourceAvailability } from '../../../services/freeBusyService.js' @@ -147,15 +146,15 @@ import logger from '../../../utils/logger.js' export default { name: 'ResourceList', components: { - MapMarker, ChevronDown, ChevronRight, - OfficeBuildingOutline, - Wrench, + MapMarker, NcCheckboxRadioSwitch, NcLoadingIcon, NcTextField, + OfficeBuildingOutline, ResourceRoomCard, + Wrench, }, props: { @@ -163,6 +162,7 @@ export default { type: Boolean, required: true, }, + calendarObjectInstance: { type: Object, required: true, @@ -220,7 +220,9 @@ export default { const buildings = new Set() for (const room of this.allRooms) { const name = room.roomBuildingName - if (name) buildings.add(name) + if (name) { + buildings.add(name) + } } return [...buildings].sort() }, @@ -231,7 +233,9 @@ export default { const features = room.roomFeatures?.split(',') ?? [] for (const f of features) { const trimmed = f.trim() - if (trimmed) facilitySet.add(trimmed) + if (trimmed) { + facilitySet.add(trimmed) + } } } return [...facilitySet].sort().map((id) => ({ @@ -293,12 +297,16 @@ export default { // Booked rooms first const aAdded = this.isRoomAdded(a) ? 0 : 1 const bAdded = this.isRoomAdded(b) ? 0 : 1 - if (aAdded !== bAdded) return aAdded - bAdded + if (aAdded !== bAdded) { + return aAdded - bAdded + } // Available before unavailable const aAvail = a.isAvailable ? 0 : 1 const bAvail = b.isAvailable ? 0 : 1 - if (aAvail !== bAvail) return aAvail - bAvail + if (aAvail !== bAvail) { + return aAvail - bAvail + } // Alphabetically return (a.displayname || '').localeCompare(b.displayname || '') @@ -323,7 +331,9 @@ export default { return Object.values(groups).sort((a, b) => { const aHasAdded = a.rooms.some((r) => this.isRoomAdded(r)) ? 0 : 1 const bHasAdded = b.rooms.some((r) => this.isRoomAdded(r)) ? 0 : 1 - if (aHasAdded !== bHasAdded) return aHasAdded - bHasAdded + if (aHasAdded !== bHasAdded) { + return aHasAdded - bHasAdded + } return a.name.localeCompare(b.name) }) }, @@ -366,7 +376,9 @@ export default { const groups = this.groupedRooms const expanded = {} if (groups.length <= 3) { - groups.forEach((g) => { expanded[g.name] = true }) + for (const g of groups) { + expanded[g.name] = true + } } else if (groups.length > 0) { expanded[groups[0].name] = true } @@ -395,10 +407,7 @@ export default { for (let i = 0; i < this.allRooms.length; i++) { const opt = options.find((o) => o.email === this.allRooms[i].emailAddress) if (opt) { - Vue.set(this.allRooms, i, { - ...this.allRooms[i], - isAvailable: opt.isAvailable, - }) + this.allRooms[i] = { ...this.allRooms[i], isAvailable: opt.isAvailable } } } } catch (error) { @@ -429,7 +438,7 @@ export default { }, toggleGroup(groupName) { - Vue.set(this.expandedGroups, groupName, !this.expandedGroups[groupName]) + this.expandedGroups[groupName] = !this.expandedGroups[groupName] }, addResource({ commonName, email, calendarUserType, language, timezoneId, roomAddress }) { @@ -456,9 +465,7 @@ export default { }, removeRoomByPrincipal(room) { - const attendee = this.resources.find( - (a) => removeMailtoPrefix(a.uri) === room.emailAddress, - ) + const attendee = this.resources.find((a) => removeMailtoPrefix(a.uri) === room.emailAddress) if (attendee) { this.removeResource(attendee) } diff --git a/src/components/Editor/Resources/ResourceRoomCard.vue b/src/components/Editor/Resources/ResourceRoomCard.vue index cc1efe2131..5d3f1dabf6 100644 --- a/src/components/Editor/Resources/ResourceRoomCard.vue +++ b/src/components/Editor/Resources/ResourceRoomCard.vue @@ -33,7 +33,7 @@