From e0b5c06892fbe79b8a7e152467db837b1d34f4ed Mon Sep 17 00:00:00 2001 From: Rikdekker Date: Wed, 12 Aug 2026 14:11:52 +0200 Subject: [PATCH 1/4] feat(resources): browsable room finder in the room picker dialog The "Show rooms" dialog listed every room in a flat table with a button per row to check its availability. With more than a handful of rooms that is not browsable: there is no way to narrow down by building, capacity or features, and no indication of what is free. Replace its contents with a room browser: rooms grouped per building, each card showing capacity, room number and whether the room is free for the time range of the event. Filters for building, floor, minimum capacity and features narrow the list down. A room is picked by clicking its card; nothing is written to the event until "Done" is pressed, so closing the dialog any other way discards the choice. The editor view itself is untouched: the quick search, the list of already selected rooms and resources, and the suggestions all stay as they are. Only the contents of the dialog changed. Search and filter logic lives in plain TypeScript (utils/roomFilter.ts) and is wrapped in a useRoomFilter composable, so it is unit tested without mounting a component and can be reused elsewhere. Two things are derived in the frontend on purpose, per review feedback on #8263: - The building name is the first segment of the building address. Rooms carry no building name of their own, so this is a heuristic, kept out of the principal model. - The LOCATION value is built here rather than taken from the dav principal, whose roomAddress joins room number, story and address in that order and returns an empty string rather than null when unset. Per-card access to the free/busy timeline is kept, so the availability detail the old dialog offered is not lost. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Rikdekker --- .../Editor/Resources/ResourceList.vue | 9 +- .../Editor/Resources/ResourceRoomCard.vue | 214 +++++++++ .../Editor/Resources/RoomPickerModal.vue | 438 ++++++++++++++++++ src/composables/useRoomFilter.ts | 97 ++++ src/types/models/roomFilter.ts | 57 +++ src/utils/attendee.js | 14 + src/utils/roomFilter.ts | 366 +++++++++++++++ .../unit/composables/useRoomFilter.test.ts | 146 ++++++ tests/javascript/unit/utils/attendee.test.js | 15 + .../javascript/unit/utils/roomFilter.test.ts | 257 ++++++++++ 10 files changed, 1608 insertions(+), 5 deletions(-) create mode 100644 src/components/Editor/Resources/ResourceRoomCard.vue create mode 100644 src/components/Editor/Resources/RoomPickerModal.vue create mode 100644 src/composables/useRoomFilter.ts create mode 100644 src/types/models/roomFilter.ts create mode 100644 src/utils/roomFilter.ts create mode 100644 tests/javascript/unit/composables/useRoomFilter.test.ts create mode 100644 tests/javascript/unit/utils/roomFilter.test.ts diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 4fd95518b6..2d635b082c 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -19,11 +19,10 @@ - + @close="setShowRoomAvailabilityModal(false)" /> + + + + + + diff --git a/src/components/Editor/Resources/RoomPickerModal.vue b/src/components/Editor/Resources/RoomPickerModal.vue new file mode 100644 index 0000000000..2af48e07dc --- /dev/null +++ b/src/components/Editor/Resources/RoomPickerModal.vue @@ -0,0 +1,438 @@ + + + + + + + diff --git a/src/composables/useRoomFilter.ts b/src/composables/useRoomFilter.ts new file mode 100644 index 0000000000..05f13bd1d1 --- /dev/null +++ b/src/composables/useRoomFilter.ts @@ -0,0 +1,97 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MaybeRefOrGetter } from 'vue' +import type { + RoomFilterOption, + RoomFilterState, + RoomGroup, + RoomOption, +} from '@/types/models/roomFilter' + +import { computed, ref, toValue } from 'vue' +import { + buildBuildingOptions, + buildFeatureOptions, + buildStoryOptions, + filterRooms, + groupRoomsByBuilding, +} from '@/utils/roomFilter' + +/** + * Search and filter state for a list of rooms + * + * @remarks + * Filter options are derived from the full room list, not from the filtered + * one, so selecting a building does not empty the other dropdowns. + * + * @param rooms All rooms to search through + * @param pinnedEmails Emails of rooms that stay visible whatever the filters say + * @return Filter state, the options to render, and the filtered results + */ +export function useRoomFilter( + rooms: MaybeRefOrGetter, + pinnedEmails: MaybeRefOrGetter = [], +) { + const searchText = ref('') + const selectedBuilding = ref(null) + const selectedStory = ref(null) + const minimumSeatingCapacity = ref(0) + const selectedFeatures = ref([]) + + const buildingOptions = computed(() => buildBuildingOptions(toValue(rooms))) + const storyOptions = computed(() => buildStoryOptions(toValue(rooms))) + const featureOptions = computed(() => buildFeatureOptions(toValue(rooms))) + + const filters = computed(() => ({ + searchText: searchText.value, + building: selectedBuilding.value, + story: selectedStory.value, + minimumSeatingCapacity: minimumSeatingCapacity.value, + features: selectedFeatures.value, + })) + + const hasActiveFilters = computed(() => { + return searchText.value.trim() !== '' + || selectedBuilding.value !== null + || selectedStory.value !== null + || minimumSeatingCapacity.value > 0 + || selectedFeatures.value.length > 0 + }) + + const roomsFiltered = computed(() => { + return filterRooms(toValue(rooms), filters.value, toValue(pinnedEmails)) + }) + + const groupedRooms = computed(() => { + return groupRoomsByBuilding(roomsFiltered.value, toValue(pinnedEmails)) + }) + + /** + * Clear every filter + */ + function resetFilters(): void { + searchText.value = '' + selectedBuilding.value = null + selectedStory.value = null + minimumSeatingCapacity.value = 0 + selectedFeatures.value = [] + } + + return { + searchText, + selectedBuilding, + selectedStory, + minimumSeatingCapacity, + selectedFeatures, + buildingOptions, + storyOptions, + featureOptions, + hasActiveFilters, + roomsFiltered, + groupedRooms, + resetFilters, + } +} diff --git a/src/types/models/roomFilter.ts b/src/types/models/roomFilter.ts new file mode 100644 index 0000000000..86e2661e15 --- /dev/null +++ b/src/types/models/roomFilter.ts @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipal } from './principal.ts' + +/** + * A room principal enriched with the outcome of a free/busy request. + * + * @remarks + * Availability is not a property of the principal itself: it only exists + * relative to a time range, so it is attached after the free/busy check + * rather than mapped from dav. + */ +export interface RoomOption extends RoomPrincipal { + /** Whether the room is free for the time range that was checked. */ + isAvailable: boolean +} + +/** + * A single choice in one of the room filter dropdowns. + */ +export interface RoomFilterOption { + /** Raw value to filter on. */ + id: string + /** Human readable and localized label. */ + label: string +} + +/** + * The state of all room filters combined. + */ +export interface RoomFilterState { + /** Free text to match against name, building, address and room number. */ + searchText: string + /** Building name to restrict to, or `null` for all buildings. */ + building: string | null + /** Building story to restrict to, or `null` for all stories. */ + story: string | null + /** Minimum number of seats, `0` to not filter on capacity. */ + minimumSeatingCapacity: number + /** Features a room must all have, `[]` to not filter on features. */ + features: string[] +} + +/** + * Rooms of one building, as rendered in a collapsible section. + */ +export interface RoomGroup { + /** Building name, or a fallback label for rooms without one. */ + name: string + /** Rooms in this building, already filtered and sorted. */ + rooms: RoomOption[] + /** How many of {@link rooms} are free for the checked time range. */ + availableCount: number +} diff --git a/src/utils/attendee.js b/src/utils/attendee.js index 66dfd3f117..069fa0c0dd 100644 --- a/src/utils/attendee.js +++ b/src/utils/attendee.js @@ -70,6 +70,20 @@ export function isPendingResourceBooking(participationStatus, scheduleStatus) { && (!scheduleStatus || scheduleStatus === '1.0') } +/** + * Get all attendees that are rooms + * + * @param {object[]} attendees Attendees of an event + * @return {object[]} Attendees with a ROOM calendar user type + */ +export function getRoomAttendees(attendees) { + if (!Array.isArray(attendees)) { + return [] + } + + return attendees.filter((attendee) => attendee?.attendeeProperty?.userType === 'ROOM') +} + /** * Check if the current user is an attendee * diff --git a/src/utils/roomFilter.ts b/src/utils/roomFilter.ts new file mode 100644 index 0000000000..95146a4d08 --- /dev/null +++ b/src/utils/roomFilter.ts @@ -0,0 +1,366 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipalProperties } from '@/types/models/principal' +import type { + RoomFilterOption, + RoomFilterState, + RoomGroup, + RoomOption, +} from '@/types/models/roomFilter' + +import { t } from '@nextcloud/l10n' + +/** + * Known room features and their localized labels. + * + * Unknown features are shown as-is: room backends are free to publish their + * own, and an untranslated raw value beats a mangled one. + * + * Evaluated lazily so that t() is not called at module import time. + * + * @return Map of raw feature value to human readable and localized label + */ +function getFeatureLabels(): Record { + return { + PROJECTOR: t('calendar', 'Projector'), + WHITEBOARD: t('calendar', 'Whiteboard'), + 'WHEELCHAIR-ACCESSIBLE': t('calendar', 'Wheelchair accessible'), + 'AV-EQUIPMENT': t('calendar', 'Audio-visual equipment'), + PHONE: t('calendar', 'Phone'), + 'VIDEO-CONFERENCING': t('calendar', 'Video conferencing'), + TV: t('calendar', 'TV'), + } +} + +/** + * Format a room feature as a human readable and localized string + * + * @param feature Raw feature value as published by the room backend + * @return Localized label, or the raw value if the feature is not known + */ +export function formatRoomFeature(feature: string): string { + return getFeatureLabels()[feature.toUpperCase()] ?? feature +} + +/** + * Trim a room property and treat a blank value as absent + * + * @param value Raw property value + * @return Trimmed value, or null if it is absent or blank + */ +function normalizeText(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + +/** + * Split an address into its segments, dropping empty ones + * + * Imported room data regularly carries empty fields, which surface as leading + * or doubled commas, e.g. ", Science Park 140, 1098 XG, Amsterdam". + * + * @param address Comma separated address + * @return Non-empty address segments in their original order + */ +function splitAddress(address: string): string[] { + return address + .split(',') + .map((segment) => segment.trim()) + .filter((segment) => segment !== '') +} + +/** + * Derive the name of the building a room is in + * + * @remarks + * A heuristic, not data: rooms have no building name of their own, so the + * first segment of the building address is used. It holds for backends that + * publish "Building, Street, Postal code, City" and degrades to the street + * for those that do not. + * + * @param room Room to derive the building name of + * @return Building name, or null if the room has no usable address + */ +export function deriveBuildingName(room: RoomPrincipalProperties): string | null { + const address = normalizeText(room.roomBuildingAddress) + if (address === null) { + return null + } + + return splitAddress(address)[0] ?? null +} + +/** + * Join address segments, merging a postal code with the city that follows it + * + * @param segments Address segments without the building name + * @return Human readable address, or an empty string if there are no segments + */ +function joinAddressSegments(segments: string[]): string { + const parts: string[] = [] + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] + const next = segments[index + 1] + // A postal code belongs with its city: "1098 XG, Amsterdam" reads as + // "1098 XG Amsterdam" on an envelope, and in a map application. + if (next !== undefined && /^\d{4,6}\s*[A-Z]{0,2}$/i.test(segment)) { + parts.push(`${segment} ${next}`) + index++ + continue + } + + parts.push(segment) + } + + return parts.join(', ') +} + +/** + * Build a location string for the event LOCATION property + * + * @remarks + * Deliberately not the `roomAddress` of the dav principal: that one joins + * room number, story and address in that order, which reads as "2.17, 2, + * Kerkstraat 10" and is of little use to a map or navigation application. + * Street first, building and room number in trailing parentheses. + * + * @param room Room to build a location for + * @return Location string, or null if the room carries no usable address data + */ +export function buildRoomLocation(room: RoomPrincipalProperties): string | null { + const roomNumber = normalizeText(room.roomBuildingRoomNumber) + const roomLabel = roomNumber === null + ? null + : t('calendar', 'Room {roomNumber}', { roomNumber }) + const address = normalizeText(room.roomBuildingAddress) + + if (address === null) { + return roomLabel + } + + const segments = splitAddress(address) + const building = segments[0] ?? null + const street = joinAddressSegments(segments.slice(1)) + const details = [building, roomLabel].filter((part) => part !== null).join(', ') + + if (street === '') { + return details === '' ? null : details + } + + return details === '' ? street : `${street} (${details})` +} + +/** + * Build the options of the building filter + * + * @param rooms Rooms to collect buildings from + * @return Unique building options, sorted by label + */ +export function buildBuildingOptions(rooms: RoomOption[]): RoomFilterOption[] { + const buildings = new Set() + for (const room of rooms) { + const building = deriveBuildingName(room) + if (building !== null) { + buildings.add(building) + } + } + + return [...buildings] + .sort((a, b) => a.localeCompare(b)) + .map((building) => ({ id: building, label: building })) +} + +/** + * Build the options of the building story filter + * + * @param rooms Rooms to collect stories from + * @return Unique story options, sorted numerically where possible + */ +export function buildStoryOptions(rooms: RoomOption[]): RoomFilterOption[] { + const stories = new Set() + for (const room of rooms) { + const story = normalizeText(room.roomBuildingStory) + if (story !== null) { + stories.add(story) + } + } + + return [...stories] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .map((story) => ({ id: story, label: story })) +} + +/** + * Build the options of the feature filter + * + * @param rooms Rooms to collect features from + * @return Unique feature options, sorted by label + */ +export function buildFeatureOptions(rooms: RoomOption[]): RoomFilterOption[] { + const features = new Set() + for (const room of rooms) { + for (const feature of room.roomFeatures ?? []) { + features.add(feature) + } + } + + return [...features] + .map((feature) => ({ id: feature, label: formatRoomFeature(feature) })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * Check whether a room matches the free text filter + * + * @param room Room to check + * @param searchText Text to search for + * @return True if the room matches + */ +function matchesSearchText(room: RoomOption, searchText: string): boolean { + const needle = searchText.trim().toLowerCase() + if (needle === '') { + return true + } + + const haystack = [ + room.displayname, + deriveBuildingName(room), + room.roomBuildingAddress, + room.roomBuildingRoomNumber, + ] + + return haystack.some((value) => value?.toLowerCase().includes(needle) ?? false) +} + +/** + * Check whether a room matches all given filters + * + * @param room Room to check + * @param filters Filters to apply + * @return True if the room matches every filter + */ +export function matchesRoomFilters(room: RoomOption, filters: RoomFilterState): boolean { + if (!matchesSearchText(room, filters.searchText)) { + return false + } + + if (filters.building !== null && deriveBuildingName(room) !== filters.building) { + return false + } + + if (filters.story !== null && normalizeText(room.roomBuildingStory) !== filters.story) { + return false + } + + if (filters.minimumSeatingCapacity > 0 + && (room.roomSeatingCapacity ?? 0) < filters.minimumSeatingCapacity) { + return false + } + + if (filters.features.length > 0) { + const features = room.roomFeatures ?? [] + if (!filters.features.every((feature) => features.includes(feature))) { + return false + } + } + + return true +} + +/** + * Filter a list of rooms + * + * @param rooms Rooms to filter + * @param filters Filters to apply + * @param pinnedEmails Emails of rooms that stay visible whatever the filters say + * @return Rooms matching the filters, plus the pinned ones + */ +export function filterRooms( + rooms: RoomOption[], + filters: RoomFilterState, + pinnedEmails: string[] = [], +): RoomOption[] { + return rooms.filter((room) => { + // A room that is already booked must remain visible, otherwise it + // cannot be deselected without first clearing the filters. + if (room.emailAddress !== null && pinnedEmails.includes(room.emailAddress)) { + return true + } + + return matchesRoomFilters(room, filters) + }) +} + +/** + * Build a comparator that sorts booked rooms first, then available ones + * + * @param pinnedEmails Emails of rooms that were already booked + * @return Comparator for {@link Array.prototype.sort} + */ +export function compareRoomsByBookingState(pinnedEmails: string[] = []): (a: RoomOption, b: RoomOption) => number { + const isPinned = (room: RoomOption) => room.emailAddress !== null + && pinnedEmails.includes(room.emailAddress) + + return (a, b) => { + if (isPinned(a) !== isPinned(b)) { + return isPinned(a) ? -1 : 1 + } + + if (a.isAvailable !== b.isAvailable) { + return a.isAvailable ? -1 : 1 + } + + return (a.displayname ?? '').localeCompare(b.displayname ?? '') + } +} + +/** + * Group rooms by the building they are in + * + * @param rooms Rooms to group, already filtered + * @param pinnedEmails Emails of rooms that were already booked + * @return Groups sorted by building name, with rooms without a building last + */ +export function groupRoomsByBuilding( + rooms: RoomOption[], + pinnedEmails: string[] = [], +): RoomGroup[] { + // Translators: heading above rooms that have no building address + const fallbackName = t('calendar', 'Other rooms') + const groups = new Map() + + for (const room of rooms) { + const name = deriveBuildingName(room) ?? fallbackName + const group = groups.get(name) + if (group === undefined) { + groups.set(name, [room]) + continue + } + + group.push(room) + } + + const compare = compareRoomsByBookingState(pinnedEmails) + + return [...groups.entries()] + .sort(([a], [b]) => { + if (a === fallbackName || b === fallbackName) { + return a === fallbackName ? 1 : -1 + } + + return a.localeCompare(b) + }) + .map(([name, groupedRooms]) => ({ + name, + rooms: [...groupedRooms].sort(compare), + availableCount: groupedRooms.filter((room) => room.isAvailable).length, + })) +} diff --git a/tests/javascript/unit/composables/useRoomFilter.test.ts b/tests/javascript/unit/composables/useRoomFilter.test.ts new file mode 100644 index 0000000000..742e694b87 --- /dev/null +++ b/tests/javascript/unit/composables/useRoomFilter.test.ts @@ -0,0 +1,146 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomOption } from '@/types/models/roomFilter' + +import { ref } from 'vue' +import { useRoomFilter } from '@/composables/useRoomFilter' + +/** + * Build a room option with sensible defaults + * + * @param overrides Properties to override + * @return Room option to use in a test + */ +function room(overrides: Partial = {}): RoomOption { + return { + id: 'principals/calendar-rooms/room-1', + displayname: 'Room 1', + emailAddress: 'room1@example.com', + calendarUserType: 'ROOM', + isAvailable: true, + roomType: 'meeting-room', + roomSeatingCapacity: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomAddress: null, + ...overrides, + } +} + +const rooms: RoomOption[] = [ + room({ + displayname: 'Aula', + emailAddress: 'aula@example.com', + roomBuildingAddress: 'Poppodium, Kerkstraat 10', + roomBuildingStory: '1', + roomSeatingCapacity: 100, + roomFeatures: ['PROJECTOR'], + }), + room({ + displayname: 'Vergaderzaal', + emailAddress: 'vergaderzaal@example.com', + roomBuildingAddress: 'Poppodium, Kerkstraat 10', + roomBuildingStory: '2', + roomSeatingCapacity: 8, + roomFeatures: ['PROJECTOR', 'WHITEBOARD'], + }), + room({ + displayname: 'Bibliotheek', + emailAddress: 'bibliotheek@example.com', + roomBuildingAddress: 'Stadskantoor, Marktplein 1', + roomSeatingCapacity: 20, + isAvailable: false, + }), +] + +describe('Test suite: useRoomFilter (composables/useRoomFilter.ts)', () => { + it('should return every room when no filter is set', () => { + const { roomsFiltered, hasActiveFilters } = useRoomFilter(rooms) + + expect(roomsFiltered.value).toHaveLength(3) + expect(hasActiveFilters.value).toBe(false) + }) + + it('should derive filter options from all rooms', () => { + const { buildingOptions, storyOptions, featureOptions } = useRoomFilter(rooms) + + expect(buildingOptions.value.map((option) => option.id)).toEqual(['Poppodium', 'Stadskantoor']) + expect(storyOptions.value.map((option) => option.id)).toEqual(['1', '2']) + expect(featureOptions.value.map((option) => option.label)).toEqual(['Projector', 'Whiteboard']) + }) + + it('should keep the options stable while a filter is applied', () => { + const { buildingOptions, selectedBuilding, roomsFiltered } = useRoomFilter(rooms) + + selectedBuilding.value = 'Poppodium' + + expect(roomsFiltered.value.map((entry) => entry.displayname)).toEqual(['Aula', 'Vergaderzaal']) + expect(buildingOptions.value).toHaveLength(2) + }) + + it('should combine filters', () => { + const { selectedBuilding, minimumSeatingCapacity, roomsFiltered } = useRoomFilter(rooms) + + selectedBuilding.value = 'Poppodium' + minimumSeatingCapacity.value = 50 + + expect(roomsFiltered.value.map((entry) => entry.displayname)).toEqual(['Aula']) + }) + + it('should search case-insensitively', () => { + const { searchText, roomsFiltered, hasActiveFilters } = useRoomFilter(rooms) + + searchText.value = 'BIBLIO' + + expect(roomsFiltered.value.map((entry) => entry.displayname)).toEqual(['Bibliotheek']) + expect(hasActiveFilters.value).toBe(true) + }) + + it('should keep pinned rooms visible while filtering', () => { + const { searchText, roomsFiltered } = useRoomFilter(rooms, ['bibliotheek@example.com']) + + searchText.value = 'aula' + + expect(roomsFiltered.value.map((entry) => entry.displayname)).toEqual(['Aula', 'Bibliotheek']) + }) + + it('should group the filtered rooms by building', () => { + const { groupedRooms } = useRoomFilter(rooms) + + expect(groupedRooms.value.map((group) => group.name)).toEqual(['Poppodium', 'Stadskantoor']) + expect(groupedRooms.value[0].availableCount).toBe(2) + expect(groupedRooms.value[1].availableCount).toBe(0) + }) + + it('should react to a changing room list', () => { + const source = ref([]) + const { buildingOptions, roomsFiltered } = useRoomFilter(source) + + expect(roomsFiltered.value).toHaveLength(0) + + source.value = rooms + + expect(roomsFiltered.value).toHaveLength(3) + expect(buildingOptions.value).toHaveLength(2) + }) + + it('should clear every filter on reset', () => { + const filter = useRoomFilter(rooms) + + filter.searchText.value = 'aula' + filter.selectedBuilding.value = 'Poppodium' + filter.selectedStory.value = '1' + filter.minimumSeatingCapacity.value = 10 + filter.selectedFeatures.value = ['PROJECTOR'] + + filter.resetFilters() + + expect(filter.hasActiveFilters.value).toBe(false) + expect(filter.roomsFiltered.value).toHaveLength(3) + }) +}) diff --git a/tests/javascript/unit/utils/attendee.test.js b/tests/javascript/unit/utils/attendee.test.js index 66a646c811..f14e797997 100644 --- a/tests/javascript/unit/utils/attendee.test.js +++ b/tests/javascript/unit/utils/attendee.test.js @@ -5,6 +5,7 @@ import { addMailtoPrefix, + getRoomAttendees, isPendingResourceBooking, organizerDisplayName, removeMailtoPrefix, @@ -63,4 +64,18 @@ describe('utils/attendee test suite', () => { expect(isPendingResourceBooking('NEEDS-ACTION', '3.7')).toEqual(false) expect(isPendingResourceBooking('NEEDS-ACTION', '5.1')).toEqual(false) }) + + it('should pick the room attendees out of a list of attendees', () => { + const room = { uri: 'room@test.com', attendeeProperty: { userType: 'ROOM' } } + const resource = { uri: 'beamer@test.com', attendeeProperty: { userType: 'RESOURCE' } } + const individual = { uri: 'user@test.com', attendeeProperty: { userType: 'INDIVIDUAL' } } + + expect(getRoomAttendees([room, resource, individual])).toEqual([room]) + }) + + it('should not choke on attendees without a user type', () => { + expect(getRoomAttendees([{ uri: 'user@test.com' }, null])).toEqual([]) + expect(getRoomAttendees([])).toEqual([]) + expect(getRoomAttendees(undefined)).toEqual([]) + }) }) diff --git a/tests/javascript/unit/utils/roomFilter.test.ts b/tests/javascript/unit/utils/roomFilter.test.ts new file mode 100644 index 0000000000..0ba4a22de5 --- /dev/null +++ b/tests/javascript/unit/utils/roomFilter.test.ts @@ -0,0 +1,257 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomFilterState, RoomOption } from '@/types/models/roomFilter' + +import { + buildBuildingOptions, + buildFeatureOptions, + buildRoomLocation, + buildStoryOptions, + compareRoomsByBookingState, + deriveBuildingName, + filterRooms, + formatRoomFeature, + groupRoomsByBuilding, + matchesRoomFilters, +} from '@/utils/roomFilter' + +/** + * Build a room option with sensible defaults + * + * @param overrides Properties to override + * @return Room option to use in a test + */ +function room(overrides: Partial = {}): RoomOption { + return { + id: 'principals/calendar-rooms/room-1', + displayname: 'Room 1', + emailAddress: 'room1@example.com', + calendarUserType: 'ROOM', + isAvailable: true, + roomType: 'meeting-room', + roomSeatingCapacity: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomAddress: null, + ...overrides, + } +} + +/** + * Build a filter state with everything disabled + * + * @param overrides Filters to enable + * @return Filter state to use in a test + */ +function filters(overrides: Partial = {}): RoomFilterState { + return { + searchText: '', + building: null, + story: null, + minimumSeatingCapacity: 0, + features: [], + ...overrides, + } +} + +describe('Test suite: Room filter (utils/roomFilter.ts)', () => { + describe('formatRoomFeature', () => { + it.for([ + ['PROJECTOR', 'Projector'], + ['projector', 'Projector'], + ['WHEELCHAIR-ACCESSIBLE', 'Wheelchair accessible'], + ['Espresso machine', 'Espresso machine'], + ])('should format %s', ([input, expected]) => { + expect(formatRoomFeature(input)).toBe(expected) + }) + }) + + describe('deriveBuildingName', () => { + it.for([ + ['Poppodium, Kerkstraat 10, 1098 XG, Amsterdam', 'Poppodium'], + // Imported data with an empty building column + [', Science Park 140, 1098 XG, Amsterdam', 'Science Park 140'], + [' Poppodium ', 'Poppodium'], + ['', null], + [' ', null], + [null, null], + ])('should derive %s', ([address, expected]) => { + expect(deriveBuildingName(room({ roomBuildingAddress: address }))).toBe(expected) + }) + }) + + describe('buildRoomLocation', () => { + it('should put the street first and the building and room in parentheses', () => { + const location = buildRoomLocation(room({ + roomBuildingAddress: 'Poppodium, Kerkstraat 10, 1098 XG, Amsterdam', + roomBuildingRoomNumber: '2.17', + })) + + expect(location).toBe('Kerkstraat 10, 1098 XG Amsterdam (Poppodium, Room 2.17)') + }) + + it('should join a postal code with the city that follows it', () => { + const location = buildRoomLocation(room({ + roomBuildingAddress: 'Poppodium, Kerkstraat 10, 1098 XG, Amsterdam', + })) + + expect(location).toBe('Kerkstraat 10, 1098 XG Amsterdam (Poppodium)') + }) + + it('should fall back to the room number without an address', () => { + const location = buildRoomLocation(room({ roomBuildingRoomNumber: '2.17' })) + + expect(location).toBe('Room 2.17') + }) + + it('should handle an address of a single segment', () => { + const location = buildRoomLocation(room({ roomBuildingAddress: 'Kerkstraat 10' })) + + expect(location).toBe('Kerkstraat 10') + }) + + it('should return null without any address data', () => { + expect(buildRoomLocation(room())).toBeNull() + }) + + it('should treat a blank address as absent', () => { + expect(buildRoomLocation(room({ roomBuildingAddress: ' ' }))).toBeNull() + }) + }) + + describe('buildBuildingOptions', () => { + it('should return unique buildings sorted by name', () => { + const options = buildBuildingOptions([ + room({ roomBuildingAddress: 'Utrecht, Straat 1' }), + room({ roomBuildingAddress: 'Amsterdam, Straat 2' }), + room({ roomBuildingAddress: 'Utrecht, Straat 3' }), + room({ roomBuildingAddress: null }), + ]) + + expect(options).toEqual([ + { id: 'Amsterdam', label: 'Amsterdam' }, + { id: 'Utrecht', label: 'Utrecht' }, + ]) + }) + }) + + describe('buildStoryOptions', () => { + it('should sort stories numerically', () => { + const options = buildStoryOptions([ + room({ roomBuildingStory: '10' }), + room({ roomBuildingStory: '2' }), + room({ roomBuildingStory: '1' }), + ]) + + expect(options.map((option) => option.id)).toEqual(['1', '2', '10']) + }) + }) + + describe('buildFeatureOptions', () => { + it('should return unique features with localized labels', () => { + const options = buildFeatureOptions([ + room({ roomFeatures: ['PROJECTOR', 'WHITEBOARD'] }), + room({ roomFeatures: ['PROJECTOR'] }), + room({ roomFeatures: null }), + ]) + + expect(options).toEqual([ + { id: 'PROJECTOR', label: 'Projector' }, + { id: 'WHITEBOARD', label: 'Whiteboard' }, + ]) + }) + }) + + describe('matchesRoomFilters', () => { + it('should match the search text against name, building, address and room number', () => { + const target = room({ + displayname: 'Aula', + roomBuildingAddress: 'Poppodium, Kerkstraat 10', + roomBuildingRoomNumber: '2.17', + }) + + expect(matchesRoomFilters(target, filters({ searchText: 'aul' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ searchText: 'poppodium' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ searchText: 'kerkstraat' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ searchText: '2.17' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ searchText: 'bibliotheek' }))).toBe(false) + }) + + it('should require every selected feature', () => { + const target = room({ roomFeatures: ['PROJECTOR', 'WHITEBOARD'] }) + + expect(matchesRoomFilters(target, filters({ features: ['PROJECTOR'] }))).toBe(true) + expect(matchesRoomFilters(target, filters({ features: ['PROJECTOR', 'WHITEBOARD'] }))).toBe(true) + expect(matchesRoomFilters(target, filters({ features: ['PROJECTOR', 'TV'] }))).toBe(false) + }) + + it('should exclude rooms without a known capacity when a minimum is set', () => { + expect(matchesRoomFilters(room({ roomSeatingCapacity: 12 }), filters({ minimumSeatingCapacity: 10 }))).toBe(true) + expect(matchesRoomFilters(room({ roomSeatingCapacity: 4 }), filters({ minimumSeatingCapacity: 10 }))).toBe(false) + expect(matchesRoomFilters(room({ roomSeatingCapacity: null }), filters({ minimumSeatingCapacity: 10 }))).toBe(false) + expect(matchesRoomFilters(room({ roomSeatingCapacity: null }), filters())).toBe(true) + }) + + it('should filter on building and story', () => { + const target = room({ + roomBuildingAddress: 'Poppodium, Kerkstraat 10', + roomBuildingStory: '2', + }) + + expect(matchesRoomFilters(target, filters({ building: 'Poppodium' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ building: 'Bibliotheek' }))).toBe(false) + expect(matchesRoomFilters(target, filters({ story: '2' }))).toBe(true) + expect(matchesRoomFilters(target, filters({ story: '3' }))).toBe(false) + }) + }) + + describe('filterRooms', () => { + it('should keep pinned rooms whatever the filters say', () => { + const booked = room({ emailAddress: 'booked@example.com', displayname: 'Booked' }) + const other = room({ emailAddress: 'other@example.com', displayname: 'Other' }) + + const result = filterRooms( + [booked, other], + filters({ searchText: 'nothing matches this' }), + ['booked@example.com'], + ) + + expect(result).toEqual([booked]) + }) + }) + + describe('compareRoomsByBookingState', () => { + it('should sort booked first, then available, then by name', () => { + const rooms = [ + room({ emailAddress: 'c@example.com', displayname: 'C', isAvailable: false }), + room({ emailAddress: 'a@example.com', displayname: 'A', isAvailable: true }), + room({ emailAddress: 'b@example.com', displayname: 'B', isAvailable: true }), + room({ emailAddress: 'booked@example.com', displayname: 'Z', isAvailable: false }), + ] + + const sorted = [...rooms].sort(compareRoomsByBookingState(['booked@example.com'])) + + expect(sorted.map((entry) => entry.displayname)).toEqual(['Z', 'A', 'B', 'C']) + }) + }) + + describe('groupRoomsByBuilding', () => { + it('should group by building and put rooms without one last', () => { + const groups = groupRoomsByBuilding([ + room({ displayname: 'A', roomBuildingAddress: 'Utrecht, Straat 1' }), + room({ displayname: 'B', roomBuildingAddress: null }), + room({ displayname: 'C', roomBuildingAddress: 'Amsterdam, Straat 2', isAvailable: false }), + room({ displayname: 'D', roomBuildingAddress: 'Amsterdam, Straat 3' }), + ]) + + expect(groups.map((group) => group.name)).toEqual(['Amsterdam', 'Utrecht', 'Other rooms']) + expect(groups[0].rooms.map((entry) => entry.displayname)).toEqual(['D', 'C']) + expect(groups[0].availableCount).toBe(1) + }) + }) +}) From f63a23406b1549e67dee6d4aec3b7acdb1d677f4 Mon Sep 17 00:00:00 2001 From: Rikdekker Date: Wed, 12 Aug 2026 14:28:56 +0200 Subject: [PATCH 2/4] fix(resources): align the room filter controls Labels sat inside the text fields but above the selects, so the filter row lined up on neither the labels nor the field boxes, and the controls had different heights. Give every filter the same shape: a label above its control, in a grid that reflows by available width. Matches the design in #8718, which labels its filters the same way and leaves the search field to its placeholder. Also collapse the search input of a closed select. vue-select keeps it next to the selected value, where it has nothing to type into and only leaves a stray caret; it regains its width when the dropdown opens. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Rikdekker --- .../Editor/Resources/RoomPickerModal.vue | 140 ++++++++++++------ 1 file changed, 92 insertions(+), 48 deletions(-) diff --git a/src/components/Editor/Resources/RoomPickerModal.vue b/src/components/Editor/Resources/RoomPickerModal.vue index 2af48e07dc..a9d11b4ce2 100644 --- a/src/components/Editor/Resources/RoomPickerModal.vue +++ b/src/components/Editor/Resources/RoomPickerModal.vue @@ -42,6 +42,10 @@ const principalsStore = usePrincipalsStore() const calendarObjectInstanceStore = useCalendarObjectInstanceStore() const searchInputId = useId() +const buildingInputId = useId() +const storyInputId = useId() +const capacityInputId = useId() +const featureInputId = useId() const allRooms = ref([]) const isLoadingAvailability = ref(false) const expandedGroups = reactive>({}) @@ -272,51 +276,63 @@ watch( contentClasses="room-picker" :buttons="dialogButtons" @update:open="emit('close')"> -
+ - - - +
+
+ + +
- - - +
+ + +
+ +
+ + +
+ +
+ + +
@@ -376,27 +392,55 @@ watch(