diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 4fd95518b6..d5bf36245e 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/ResourceRoomType.vue b/src/components/Editor/Resources/ResourceRoomType.vue index a2ed54bc07..7c22210605 100644 --- a/src/components/Editor/Resources/ResourceRoomType.vue +++ b/src/components/Editor/Resources/ResourceRoomType.vue @@ -24,7 +24,7 @@ + + + + diff --git a/src/composables/useRoomFilter.ts b/src/composables/useRoomFilter.ts new file mode 100644 index 0000000000..2f57f95a2b --- /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 '@/utils/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/models/resourceProps.js b/src/models/resourceProps.js deleted file mode 100644 index 4e11b46b1a..0000000000 --- a/src/models/resourceProps.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { translate as t } from '@nextcloud/l10n' - -/** - * Return all supported room types - * - * @return {{label: string, value: string}[]} List of supported room types - */ -export function getAllRoomTypes() { - return [ - { value: 'meeting-room', label: t('calendar', 'Meeting room') }, - { value: 'lecture-hall', label: t('calendar', 'Lecture hall') }, - { value: 'seminar-room', label: t('calendar', 'Seminar room') }, - { value: 'other', label: t('calendar', 'Other') }, - ] -} - -/** - * Format room type as a human readable and localized string - * - * @param {string} value Raw room type - * @return {string|null} Human readable and localized room type or null if given raw value is invalid - */ -export function formatRoomType(value) { - const option = getAllRoomTypes().find((option) => option.value === value) - return option?.label ?? null -} diff --git a/src/models/resourceProps.ts b/src/models/resourceProps.ts new file mode 100644 index 0000000000..35e003bb24 --- /dev/null +++ b/src/models/resourceProps.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' + +/** + * Return all supported room types + * + * @return List of supported room types + */ +export function getAllRoomTypes() { + return [ + { value: 'meeting-room', label: t('calendar', 'Meeting room') }, + { value: 'lecture-hall', label: t('calendar', 'Lecture hall') }, + { value: 'seminar-room', label: t('calendar', 'Seminar room') }, + { value: 'other', label: t('calendar', 'Other') }, + ] +} + +/** + * Format room type as a human readable and localized string + * + * @param value Raw room type + * @return Human readable and localized room type or null if given raw value is invalid + */ +export function formatRoomType(value: string): string | null { + const option = getAllRoomTypes().find((option) => option.value === value) + return option?.label ?? null +} + +/** + * 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 +} 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..67723b2360 --- /dev/null +++ b/src/utils/roomFilter.ts @@ -0,0 +1,327 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipal } from '@/types/models/principal' + +import { t } from '@nextcloud/l10n' +import { formatRoomFeature } from '@/models/resourceProps' + +/** + * A room principal enriched with the outcome of a free/busy request. + */ +export interface RoomOption { + principal: 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 +} + +/** + * 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 !== '') +} + +/** + * Check whether an address segment is a postal code + * + * @param segment Address segment to check + * @return True if the segment looks like a postal code + */ +function isPostalCode(segment: string): boolean { + return /^\d{4,6}(\s*[A-Z]{1,2})?$/i.test(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, + * or to the city, for those that do not. Postal codes are skipped: they are + * never a building name, and grouping rooms under "1098 XG" helps nobody. + * + * @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: RoomOption): string | null { + // TODO https://github.com/nextcloud/calendar/issues/8734 Use the newly introduced building name after it becomes available. + const address = normalizeText(room.principal.roomBuildingAddress) + if (address === null) { + return null + } + + return splitAddress(address).find((segment) => !isPostalCode(segment)) ?? null +} + +/** + * 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.principal.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.principal.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.principal.displayname, + deriveBuildingName(room), + room.principal.roomBuildingAddress, + room.principal.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.principal.roomBuildingStory) !== filters.story) { + return false + } + + if (filters.minimumSeatingCapacity > 0 + && (room.principal.roomSeatingCapacity ?? 0) < filters.minimumSeatingCapacity) { + return false + } + + if (filters.features.length > 0) { + const features = room.principal.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.principal.emailAddress !== null && pinnedEmails.includes(room.principal.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.principal.emailAddress !== null + && pinnedEmails.includes(room.principal.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.principal.displayname ?? '').localeCompare(b.principal.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..e577197b0a --- /dev/null +++ b/tests/javascript/unit/composables/useRoomFilter.test.ts @@ -0,0 +1,149 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipal } from '@/types/models/principal' +import type { RoomOption } from '@/utils/roomFilter' + +import { ref } from 'vue' +import { useRoomFilter } from '@/composables/useRoomFilter' + +/** + * Build a room option with sensible defaults + * + * @param overrides room properties to override + * @param isAvailable Availibility status + * @return Room option to use in a test + */ +function room(overrides: Partial = {}, isAvailable = true): RoomOption { + return { + principal: { + id: 'principals/calendar-rooms/room-1', + displayname: 'Room 1', + emailAddress: 'room1@example.com', + calendarUserType: 'ROOM', + roomType: 'meeting-room', + roomSeatingCapacity: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomAddress: null, + ...overrides, + }, + isAvailable, + } +} + +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, + }, 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.principal.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.principal.displayname)).toEqual(['Aula']) + }) + + it('should search case-insensitively', () => { + const { searchText, roomsFiltered, hasActiveFilters } = useRoomFilter(rooms) + + searchText.value = 'BIBLIO' + + expect(roomsFiltered.value.map((entry) => entry.principal.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.principal.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/models/resourceProps.test.ts b/tests/javascript/unit/models/resourceProps.test.ts new file mode 100644 index 0000000000..2e69317afd --- /dev/null +++ b/tests/javascript/unit/models/resourceProps.test.ts @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { formatRoomFeature } from '@/models/resourceProps' + +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) + }) +}) 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..566421ee80 --- /dev/null +++ b/tests/javascript/unit/utils/roomFilter.test.ts @@ -0,0 +1,214 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipal } from '@/types/models/principal' +import type { RoomFilterState, RoomOption } from '@/utils/roomFilter' + +import { + buildBuildingOptions, + buildFeatureOptions, + buildStoryOptions, + compareRoomsByBookingState, + deriveBuildingName, + filterRooms, + groupRoomsByBuilding, + matchesRoomFilters, +} from '@/utils/roomFilter' + +/** + * Build a room option with sensible defaults + * + * @param overrides room properties to override + * @param isAvailable Availibility status + * @return Room option to use in a test + */ +function room(overrides: Partial = {}, isAvailable = true): RoomOption { + return { + principal: { + id: 'principals/calendar-rooms/room-1', + displayname: 'Room 1', + emailAddress: 'room1@example.com', + calendarUserType: 'ROOM', + roomType: 'meeting-room', + roomSeatingCapacity: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomAddress: null, + ...overrides, + }, + isAvailable, + } +} + +/** + * 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('deriveBuildingName', () => { + it.for([ + ['Poppodium, Kerkstraat 10, 1098 XG, Amsterdam', 'Poppodium'], + // Imported data with an empty building column: degrades to the street + [', Science Park 140, 1098 XG, Amsterdam', 'Science Park 140'], + // Without a building and a street: degrades to the city, never the + // postal code, which is no use as a group heading + ['1098 XG, Amsterdam', 'Amsterdam'], + ['01324 Dresden', '01324 Dresden'], + [' Poppodium ', 'Poppodium'], + ['1098 XG', null], + ['', null], + [' ', null], + [null, null], + ])('should derive %s', ([address, expected]) => { + expect(deriveBuildingName(room({ roomBuildingAddress: address }))).toBe(expected) + }) + }) + + 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' }, false), + room({ emailAddress: 'a@example.com', displayname: 'A' }, true), + room({ emailAddress: 'b@example.com', displayname: 'B' }, true), + room({ emailAddress: 'booked@example.com', displayname: 'Z' }, false), + ] + + const sorted = [...rooms].sort(compareRoomsByBookingState(['booked@example.com'])) + + expect(sorted.map((entry) => entry.principal.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' }, 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.principal.displayname)).toEqual(['D', 'C']) + expect(groups[0].availableCount).toBe(1) + }) + }) +})