diff --git a/apps/signage-manager/USER_STORIES.md b/apps/signage-manager/USER_STORIES.md index 4bfbdebcdc6..0676ed175ef 100644 --- a/apps/signage-manager/USER_STORIES.md +++ b/apps/signage-manager/USER_STORIES.md @@ -228,6 +228,7 @@ These stories cover the current app workflows: **Acceptance Criteria:** - The zones page lists signage zones and supports direct routes to a selected zone. +- The header count shows the number of signage zones. When users search in a zone, it shows the number of results. If the count cannot load, the header does not show it. - System administrators and signage group managers can create, edit, and delete signage zones. - New and edited signage zones keep the `signage` tag and require a parent from the active group's accessible zone tree. - Zone management controls are not shown for untagged parent zones in the tree. @@ -237,6 +238,7 @@ These stories cover the current app workflows: - The display tab shows displays assigned to the zone. - Users with update permission can add displays to the zone. - When the display or zone search in an add dialog fails, the dialog shows an error with a retry, not an empty result. +- While the zone tree or a tab loads, it shows a loading state. If a zone list, the playlist tab, or the display tab cannot load, it shows an error with a retry button. The error shows above the zones that loaded. --- @@ -255,6 +257,8 @@ These stories cover the current app workflows: - The playlist tab shows playlists assigned directly to the display and their status. - Users with update permission can add or remove direct playlist assignments. - The zone tab shows zones assigned to the display. +- While the display list or a tab loads, it shows a loading state. If the list, the playlist tab, or the zone tab cannot load, it shows an error with a retry button. +- If a link opens a display or zone that cannot load, an error message shows. --- diff --git a/apps/signage-manager/src/app/displays/display-content.component.ts b/apps/signage-manager/src/app/displays/display-content.component.ts index 7000618be12..c15aa1b4885 100644 --- a/apps/signage-manager/src/app/displays/display-content.component.ts +++ b/apps/signage-manager/src/app/displays/display-content.component.ts @@ -2,7 +2,11 @@ import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; @@ -215,6 +219,21 @@ type PlaylistStatus = 'expired' | 'pending' | 'awaiting_approval' | null; } } + } @else if ( + has_assigned_playlists() && + playlists_loading() + ) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if ( + has_assigned_playlists() && + playlists_error() + ) { + } @else {
} + } @else if (display_zones_loading()) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if (display_zones_error()) { + } @else {
!!this.selected_display()?.playlists?.length, + ); + + public reloadPlaylists() { + this._playlist_service.reloadPlaylists(); + } + public readonly display_zones = this._display_service.selected_display_zones; + public readonly display_zones_loading = + this._display_service.selected_display_zones_loading; + public readonly display_zones_error = + this._display_service.selected_display_zones_error; + + public reloadZones() { + this._display_service.reloadSelectedDisplayZones(); + } public addPlaylist() { const display = this.selected_display(); diff --git a/apps/signage-manager/src/app/displays/display-list.component.ts b/apps/signage-manager/src/app/displays/display-list.component.ts index d92fb0ada1d..4a8503648c7 100644 --- a/apps/signage-manager/src/app/displays/display-list.component.ts +++ b/apps/signage-manager/src/app/displays/display-list.component.ts @@ -18,6 +18,7 @@ import { RouterLink } from '@angular/router'; import { DateFromPipe, IconComponent, + LoadErrorComponent, TranslatePipe, } from '@placeos/components'; import { isSameDay } from 'date-fns'; @@ -41,8 +42,7 @@ import { SignageDisplayService } from './signage-display.service'; [placeholder]=" 'SIGNAGE_MANAGER.SEARCH_DISPLAYS' | translate " - [ngModel]="search()" - (ngModelChange)="search.set($event)" + [(ngModel)]="search" [attr.aria-label]=" 'SIGNAGE_MANAGER.SEARCH_DISPLAYS' | translate " @@ -119,6 +119,8 @@ import { SignageDisplayService } from './signage-display.service'; intersect (intersect)="loadMore()" >
+ } @else if (error()) { + } @else if (!loading()) {
{{ 'COMMON.END_OF_LIST' | translate }} @@ -131,6 +133,8 @@ import { SignageDisplayService } from './signage-display.service'; > {{ 'COMMON.LOADING' | translate }}
+ } @else if (error()) { + } @else {
- this._context.templates_enabled() - ? this.selected_display()?.id - : undefined, - loader: ({ params }) => - this._template_service.listTemplateMappings({ - control_system_id: params, - }), - }); - public readonly templates_loading = this._template_mappings.isLoading; - public readonly templates_error = this._template_mappings.error; - public readonly template_mappings = computed(() => - this._template_mappings.hasValue() - ? this._template_mappings.value() - : [], - ); + public readonly templates_loading = + this._display_service.selected_display_template_mappings_loading; + public readonly templates_error = + this._display_service.selected_display_template_mappings_error; + public readonly template_mappings = + this._display_service.selected_display_template_mappings; public readonly day_blocks = computed(() => buildDisplayScheduleDays( diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts index dfd2f263813..bc104b82968 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -4,9 +4,7 @@ import { effect, inject, input, - resource, signal, - untracked, } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -17,10 +15,10 @@ import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; import { SignageContextService } from '../signage-context.service'; -import { SignageTemplateService } from '../templates/signage-template.service'; import { DisplayContentComponent } from './display-content.component'; import { DisplayHeaderComponent } from './display-header.component'; import { DisplayListComponent } from './display-list.component'; +import { selectRoutedItem } from './routed-selection.util'; import { showSignageDisplay } from './signage-display'; import { SignageDisplayService } from './signage-display.service'; @@ -339,7 +337,6 @@ export class DisplaysSectionComponent { private readonly _context = inject(SignageContextService); private readonly _display_service = inject(SignageDisplayService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _template_service = inject(SignageTemplateService); private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); @@ -354,33 +351,14 @@ export class DisplaysSectionComponent { public readonly can_update = this._context.can_update; public readonly can_delete_displays = this._context.can_delete_displays; - private readonly _displays = this._display_service.displays; - - private readonly _template_mappings = resource({ - params: () => { - const id: string = this.selected_display()?.id; - return this.templates_enabled() && id - ? { - id, - revision: - this._template_service.template_mappings_revision(), - } - : undefined; - }, - loader: ({ params }) => - this._template_service.listTemplateMappings({ - control_system_id: params.id, - }), - }); - public readonly template_count_loading = this._template_mappings.isLoading; + public readonly template_count_loading = + this._display_service.selected_display_template_mappings_loading; public readonly playlist_count_loading = this._playlist_service.playlists_loading; public readonly zone_count_loading = this._display_service.selected_display_zones_loading; - public readonly template_count = computed(() => - this._template_mappings.hasValue() - ? this._template_mappings.value().length - : 0, + public readonly template_count = computed( + () => this._display_service.selected_display_template_mappings().length, ); public readonly playlist_count = computed( @@ -400,10 +378,6 @@ export class DisplaysSectionComponent { return `${signage_path.replace(/\/$/, '')}/#/signage/${encodeURIComponent(display.id)}?debug=true`; }); - private _route_resolved = false; - // Last display id fetched for a link, so a missing id is fetched once - private _requested_id = ''; - constructor() { effect(() => { const route_tab = parseDisplayTab(this.tab()); @@ -416,47 +390,15 @@ export class DisplaysSectionComponent { } }); - effect(() => { - const id = this.id(); - const list = this._displays(); - if (id) { - const match = list.find((d) => d.id === id); - if (match) { - if ( - this._display_service.selected_display()?.id !== - match.id - ) { - this._display_service.selected_display.set(match); - } - this._route_resolved = true; - } else if ( - untracked(this._display_service.selected_display)?.id !== id - ) { - // The list holds only the pages loaded so far - untracked(() => this._loadDisplay(id)); - } - } else if (this._route_resolved) { - this._display_service.selected_display.set(null); - } + selectRoutedItem({ + id: this.id, + list: this._display_service.displays, + selected: this._display_service.selected_display, + // The list holds only the pages loaded so far + load: showSignageDisplay, }); } - /** Select a display from a link that the loaded pages do not include */ - private async _loadDisplay(id: string) { - if (this._requested_id === id) return; - this._requested_id = id; - const display = await showSignageDisplay(id).catch(() => null); - if ( - !display || - this.id() !== id || - this._display_service.selected_display()?.id === id - ) { - return; - } - this._display_service.selected_display.set(display); - this._route_resolved = true; - } - public deselectDisplay() { this._display_service.selected_display.set(null); this._router.navigate(['/displays'], {}); diff --git a/apps/signage-manager/src/app/displays/routed-selection.util.ts b/apps/signage-manager/src/app/displays/routed-selection.util.ts new file mode 100644 index 00000000000..1784b908195 --- /dev/null +++ b/apps/signage-manager/src/app/displays/routed-selection.util.ts @@ -0,0 +1,63 @@ +import { effect, untracked, type WritableSignal } from '@angular/core'; +import { i18n, notifyError } from '@placeos/common'; + +/** + * Select the item that the route names, for a page with a list and details. + * Takes the item from the loaded list, or loads it by id when the list does + * not hold it, such as a link to an item past the loaded pages. Clears the + * selection when the route drops the id after it selected an item. + * + * Creates an effect, so call it in an injection context. + */ +export function selectRoutedItem(options: { + /** Id in the route, empty when the route has no item */ + id: () => string; + /** Items loaded so far */ + list: () => readonly T[]; + selected: WritableSignal; + /** Load one item by id. A rejection shows an error. */ + load: (id: string) => Promise; +}) { + const { id, list, selected, load } = options; + let route_resolved = false; + // Id the route had when the effect last ran, and the id loaded for it. + // The load runs once each time the route moves to an id, so going back + // to an id loaded before loads it again. + let route_id_seen = ''; + let requested_id = ''; + + async function loadItem(item_id: string) { + const item = await load(item_id).catch(() => null); + if (id() !== item_id || untracked(selected)?.id === item_id) return; + if (!item) { + notifyError(i18n('COMMON.LOAD_ERROR')); + return; + } + selected.set(item); + route_resolved = true; + } + + effect(() => { + const route_id = id(); + const items = list(); + if (route_id !== route_id_seen) { + route_id_seen = route_id; + requested_id = ''; + } + if (!route_id) { + if (route_resolved) selected.set(null); + return; + } + const match = items.find((item) => item.id === route_id); + if (match) { + if (selected()?.id !== match.id) selected.set(match); + route_resolved = true; + } else if ( + untracked(selected)?.id !== route_id && + requested_id !== route_id + ) { + requested_id = route_id; + untracked(() => loadItem(route_id)); + } + }); +} diff --git a/apps/signage-manager/src/app/displays/signage-display.service.ts b/apps/signage-manager/src/app/displays/signage-display.service.ts index 5d84e125cb0..77144ce77d6 100644 --- a/apps/signage-manager/src/app/displays/signage-display.service.ts +++ b/apps/signage-manager/src/app/displays/signage-display.service.ts @@ -36,6 +36,7 @@ import { queryAll, searchParam, } from '../signage-service.util'; +import { SignageTemplateService } from '../templates/signage-template.service'; import { SignageZoneService } from '../zones/signage-zone.service'; import { type ZoneNode } from './display-zones.util'; import { @@ -58,6 +59,7 @@ export class SignageDisplayService { private readonly _context = inject(SignageContextService); private readonly _zone_service = inject(SignageZoneService); private readonly _playlist_service = inject(SignagePlaylistService); + private readonly _template_service = inject(SignageTemplateService); private readonly _display_overrides = signal>( {}, @@ -96,8 +98,11 @@ export class SignageDisplayService { ); public readonly displays_loading = this._display_list.loading; public readonly displays_has_more = this._display_list.has_more; + /** Whether the last page of displays failed to load */ + public readonly displays_error = this._display_list.error; /** Number of displays the server has for the current query */ public readonly displays_total = this._display_list.total; + private readonly _displays_reload = signal(0); private readonly _reload_displays = effect(() => { const initialised = this._org.initialised(); @@ -105,6 +110,7 @@ export class SignageDisplayService { const group_id = this._context.api_group_id_debounced.value(); const search = this._display_search_debounced.value().trim(); this._context.data_change(); + this._displays_reload(); untracked(() => { // A data change on the same query keeps the loaded rows on screen // and reloads as many rows as were loaded, so the list does not @@ -163,6 +169,14 @@ export class SignageDisplayService { this._display_list.loadMore(); } + /** Load the display page that failed again: the next page when some + * pages are loaded, otherwise the first page. */ + public retryDisplays() { + if (!this._display_list.retry()) { + this._displays_reload.update((count) => count + 1); + } + } + // Cleared when the user switches group public readonly selected_display = linkedSignal( { @@ -214,6 +228,13 @@ export class SignageDisplayService { ); public readonly selected_display_zones_loading = this._selected_display_zones.isLoading; + public readonly selected_display_zones_error = computed( + () => !!this._selected_display_zones.error(), + ); + + public reloadSelectedDisplayZones() { + this._selected_display_zones.reload(); + } /** * Displays in the selected zone. Queried by zone, as the display list @@ -244,6 +265,44 @@ export class SignageDisplayService { }); public readonly selected_zone_displays_loading = this._selected_zone_displays.isLoading; + public readonly selected_zone_displays_error = computed( + () => !!this._selected_zone_displays.error(), + ); + + public reloadSelectedZoneDisplays() { + this._selected_zone_displays.reload(); + } + + /** + * Template mappings of the selected display, loaded once for the tab + * count and the schedule. Empty when templates are off. + */ + private readonly _selected_display_template_mappings = resource({ + params: () => { + const id = this.selected_display()?.id; + return this._context.templates_enabled() && id + ? { + id, + revision: + this._template_service.template_mappings_revision(), + } + : undefined; + }, + loader: ({ params }) => + this._template_service.listTemplateMappings({ + control_system_id: params.id, + }), + }); + public readonly selected_display_template_mappings = computed(() => + this._selected_display_template_mappings.hasValue() + ? this._selected_display_template_mappings.value() + : [], + ); + public readonly selected_display_template_mappings_loading = + this._selected_display_template_mappings.isLoading; + public readonly selected_display_template_mappings_error = computed( + () => !!this._selected_display_template_mappings.error(), + ); /** * Keep a saved display until the lists reload. @@ -344,6 +403,8 @@ export class SignageDisplayService { if (!result) return null; const saved = this._addDisplayToList(result); this.selected_display.set(saved); + // Zone, schedule and report views hold their own copies of displays + this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_SAVED')); return saved; } @@ -390,6 +451,7 @@ export class SignageDisplayService { if (this.selected_display()?.id === display.id) { this.selected_display.set(null); } + this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_REMOVED')); return true; } @@ -404,11 +466,12 @@ export class SignageDisplayService { ? [active_zone] : []; if (group_id && !roots.length) { - const result = await queryZones({ - group_id, - limit: 500, - include_children_count: true, - } as any).catch(() => null); + const result = await queryZones( + this._context.groupQueryParams( + { limit: 500, include_children_count: true }, + group_id, + ), + ).catch(() => null); roots = (result?.data || []).map(decodeEntityNames); } return this._zone_service.zoneIdsWithAncestors(roots); diff --git a/apps/signage-manager/src/app/zones/signage-zone.service.ts b/apps/signage-manager/src/app/zones/signage-zone.service.ts index 540046a5c12..019518e0293 100644 --- a/apps/signage-manager/src/app/zones/signage-zone.service.ts +++ b/apps/signage-manager/src/app/zones/signage-zone.service.ts @@ -5,6 +5,7 @@ import { inject, Injectable, linkedSignal, + type Resource, resource, signal, untracked, @@ -34,6 +35,17 @@ import { SignageContextService } from '../signage-context.service'; import { dialogClosed, mergeItems } from '../signage-service.util'; import type { ZoneEditFormModel } from './zone-edit-modal.component'; +/** One load of a zone list, with the number of zones the server has */ +interface ZoneList { + zones: PlaceZone[]; + total: number; +} + +/** Zones of a zone list resource, empty while it loads or after it fails */ +function loadedZones(list: Resource) { + return list.hasValue() ? list.value().zones : []; +} + /** Signage zones, the selected zone, and the playlists assigned to zones */ @Injectable({ providedIn: 'root', @@ -77,13 +89,13 @@ export class SignageZoneService { parent_id, limit: 2500, include_children_count: true, - } as any); + }); } /** * Zones of the debounced group, loaded again after each save. Empty when - * the lists cannot be queried, when `load` gives no query or when the - * query fails. + * the lists cannot be queried or when `load` gives no query. A failed + * query puts the resource in its error state. */ private _zoneResource( load: (group_id: string) => QueryResponse | null, @@ -95,13 +107,16 @@ export class SignageZoneService { group_id: this._context.api_group_id_debounced.value(), can_query: this._context.can_query_group_data(), }), - loader: async ({ params }) => { + loader: async ({ params }): Promise => { const query = params.initialised && params.can_query ? load(params.group_id) : null; - const result = await query?.catch(() => null); - return (result?.data || []).map(decodeEntityNames); + const result = await query; + return { + zones: (result?.data || []).map(decodeEntityNames), + total: result?.total || 0, + }; }, }); } @@ -111,11 +126,18 @@ export class SignageZoneService { this._context.groupQueryParams( { limit: 250, tags: 'signage' }, group_id, - ) as any, + ), ), ); public readonly zones = computed(() => - mergeItems(this._zone_list.value() || [], this._zone_overrides()), + mergeItems(loadedZones(this._zone_list), this._zone_overrides()), + ); + /** + * Number of signage zones the server has, loaded or not. Null while the + * count loads or after it fails, as the count is not known. + */ + public readonly signage_zone_count = computed(() => + this._zone_list.hasValue() ? this._zone_list.value().total : null, ); private readonly _all_zone_list = this._zoneResource((group_id) => @@ -127,7 +149,7 @@ export class SignageZoneService { ), ); public readonly all_zones = computed(() => - mergeItems(this._all_zone_list.value() || [], this._zone_overrides()), + mergeItems(loadedZones(this._all_zone_list), this._zone_overrides()), ); // A selected group has its own zones as roots, which the all zones list @@ -139,20 +161,53 @@ export class SignageZoneService { limit: 500, include_children_count: true, parent_id: 'root', - } as any), + }), ); public readonly root_zones = computed(() => { if (this._context.api_group_id_debounced.value()) { return this.all_zones(); } const org_zone_id = this._org.organisation?.id; - const zones = this._org_root_list.value() || []; + const zones = loadedZones(this._org_root_list); return mergeItems( org_zone_id ? zones.filter(({ id }) => id === org_zone_id) : zones, this._zone_overrides(), ); }); + /** Whether the zone lists of the zones page are loading */ + public readonly zones_loading = computed( + () => + this._zone_list.isLoading() || + this._all_zone_list.isLoading() || + this._org_root_list.isLoading(), + ); + /** + * Whether a zone list of the zones page failed to load. The tree can + * still show the lists that loaded, so it shows this beside them. + */ + public readonly zones_error = computed( + () => + !!this._zone_list.error() || + !!this._all_zone_list.error() || + !!this._org_root_list.error(), + ); + + /** + * Load the zone lists that failed again. Includes the signage zone list, + * which the header count and zone pickers read. + */ + public reloadZones() { + const lists = [ + this._zone_list, + this._all_zone_list, + this._org_root_list, + ]; + for (const list of lists) { + if (list.error()) list.reload(); + } + } + /** Zone tree callbacks for the modals that pick zones */ public zoneTreeData() { return { diff --git a/apps/signage-manager/src/app/zones/zone-content.component.ts b/apps/signage-manager/src/app/zones/zone-content.component.ts index 3e2c12219fb..ad8b65f4e28 100644 --- a/apps/signage-manager/src/app/zones/zone-content.component.ts +++ b/apps/signage-manager/src/app/zones/zone-content.component.ts @@ -2,7 +2,11 @@ import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; @@ -185,6 +189,19 @@ type PlaylistStatus = 'expired' | 'pending' | 'awaiting_approval' | null; }
} + } @else if ( + has_assigned_playlists() && playlists_loading() + ) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if ( + has_assigned_playlists() && playlists_error() + ) { + } @else {
} + } @else if (zone_displays_loading()) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if (zone_displays_error()) { + } @else {
!!this.selected_zone()?.playlists?.length, + ); + + public reloadPlaylists() { + this._playlist_service.reloadPlaylists(); + } + public readonly zone_displays = this._display_service.selected_zone_displays; + public readonly zone_displays_loading = + this._display_service.selected_zone_displays_loading; + public readonly zone_displays_error = + this._display_service.selected_zone_displays_error; + + public reloadDisplays() { + this._display_service.reloadSelectedZoneDisplays(); + } public addPlaylist() { const zone = this.selected_zone(); diff --git a/apps/signage-manager/src/app/zones/zone-header.component.ts b/apps/signage-manager/src/app/zones/zone-header.component.ts index 2539971c720..f0ef35747bd 100644 --- a/apps/signage-manager/src/app/zones/zone-header.component.ts +++ b/apps/signage-manager/src/app/zones/zone-header.component.ts @@ -18,14 +18,15 @@ import { SignageZoneService } from './signage-zone.service'; {{ 'SIGNAGE_MANAGER.ZONES_TITLE' | translate }}
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
+ @let count = total_count(); + @if (count !== null) { +
+ {{ + 'COMMON.ITEM_COUNT' + | translate: { count } : count + }} +
+ }
@@ -61,8 +62,14 @@ export class ZoneHeaderComponent { private readonly _zone_service = inject(SignageZoneService); private readonly _router = inject(Router); - public readonly total_count = computed( - () => this._zone_service.filtered_zones().length, + /** Search results while searching, otherwise the server total of + * signage zones, as the tree also shows untagged parent zones. Null + * when the total is not known. */ + public readonly total_count = computed(() => + this._zone_service.selected_zone()?.id && + this._zone_service.zone_search_term().trim() + ? this._zone_service.filtered_zones().length + : this._zone_service.signage_zone_count(), ); public readonly can_manage_zones = this._context.can_manage_zones; diff --git a/apps/signage-manager/src/app/zones/zone-list.component.ts b/apps/signage-manager/src/app/zones/zone-list.component.ts index 714b1825a47..7b55a017509 100644 --- a/apps/signage-manager/src/app/zones/zone-list.component.ts +++ b/apps/signage-manager/src/app/zones/zone-list.component.ts @@ -14,7 +14,11 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router, RouterLink } from '@angular/router'; import { OrganisationService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; import { PlaceZone } from '@placeos/ts-client'; import { SignageZoneService } from './signage-zone.service'; @@ -55,8 +59,7 @@ interface FlatZoneTreeNode extends ZoneTreeNode { '', } " - [ngModel]="search()" - (ngModelChange)="search.set($event)" + [(ngModel)]="search" [attr.aria-label]=" 'SIGNAGE_MANAGER.SEARCH_IN_ZONE' | translate @@ -72,6 +75,10 @@ interface FlatZoneTreeNode extends ZoneTreeNode {
@if (tree_nodes().length) { + @if (error()) { + + + } + } @else if (loading()) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if (error()) { + } @else {
!!this.selected()?.id); public readonly show_search_results = computed( () => this.search_enabled() && !!this.search().trim(), @@ -392,6 +411,10 @@ export class ZoneListComponent { this.loadNodeChildren(current); } + public retry() { + this._zone_service.reloadZones(); + } + public retryChildren(node: ZoneTreeNode) { this.loadNodeChildren(node); } diff --git a/apps/signage-manager/src/app/zones/zones.component.ts b/apps/signage-manager/src/app/zones/zones.component.ts index b7a589bc493..e31028ce6a1 100644 --- a/apps/signage-manager/src/app/zones/zones.component.ts +++ b/apps/signage-manager/src/app/zones/zones.component.ts @@ -6,13 +6,13 @@ import { input, resource, signal, - untracked, } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { showZone } from '@placeos/ts-client'; +import { selectRoutedItem } from '../displays/routed-selection.util'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; @@ -316,11 +316,9 @@ export class ZonesSectionComponent { ); }); - private readonly _zones = this._zone_service.all_zones; - private readonly _template_mappings = resource({ params: () => { - const id: string = this.selected_zone()?.id; + const id = this.selected_zone()?.id; return this.templates_enabled() && id ? { id, @@ -354,10 +352,6 @@ export class ZonesSectionComponent { () => this._display_service.selected_zone_displays().length, ); - private _route_resolved = false; - // Last zone id fetched for a link, so a missing id is fetched once - private _requested_id = ''; - constructor() { effect(() => { const route_tab = parseZoneTab(this.tab()); @@ -370,44 +364,15 @@ export class ZonesSectionComponent { } }); - effect(() => { - const id = this.id(); - const list = this._zones(); - if (id) { - const match = list.find((z) => z.id === id); - if (match) { - if (this._zone_service.selected_zone()?.id !== match.id) { - this._zone_service.selected_zone.set(match); - } - this._route_resolved = true; - } else if ( - untracked(this._zone_service.selected_zone)?.id !== id - ) { - // `all_zones` holds only the first 500 zones of the group - untracked(() => this._loadZone(id)); - } - } else if (this._route_resolved) { - this._zone_service.selected_zone.set(null); - } + selectRoutedItem({ + id: this.id, + list: this._zone_service.all_zones, + selected: this._zone_service.selected_zone, + // `all_zones` holds only the first 500 zones of the group + load: async (id) => decodeEntityNames(await showZone(id)), }); } - /** Select a zone from a link that the loaded zones do not include */ - private async _loadZone(id: string) { - if (this._requested_id === id) return; - this._requested_id = id; - const zone = await showZone(id).catch(() => null); - if ( - !zone || - this.id() !== id || - this._zone_service.selected_zone()?.id === id - ) { - return; - } - this._zone_service.selected_zone.set(decodeEntityNames(zone)); - this._route_resolved = true; - } - public deselectZone() { this._zone_service.selected_zone.set(null); this._router.navigate(['/zones'], {}); diff --git a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts index 8823c8505ec..8ac12fc9687 100644 --- a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { DisplayContentComponent } from '../../app/displays/display-content.component'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; @@ -16,10 +17,19 @@ describe('DisplayContentComponent', () => { const can_update = signal(true); const add_playlist = vi.fn(); const remove_playlist = vi.fn(); + const zones_loading = signal(false); + const zones_error = signal(false); + const playlists_loading = signal(false); + const playlists_error = signal(false); + const reload_playlists = vi.fn(); + const reload_zones = vi.fn(); const context_stub = { can_update }; const display_stub = { selected_display, selected_display_zones, + selected_display_zones_loading: zones_loading, + selected_display_zones_error: zones_error, + reloadSelectedDisplayZones: reload_zones, addPlaylistToDisplay: add_playlist, removePlaylistFromDisplay: remove_playlist, }; @@ -28,12 +38,16 @@ describe('DisplayContentComponent', () => { playlists().filter(({ id }) => ids.includes(id)), playlist_approval_status, playlist_thumbnail_media, + playlists_loading, + playlists_error, + reloadPlaylists: reload_playlists, }; async function make() { await TestBed.configureTestingModule({ imports: [DisplayContentComponent], providers: [ + provideRouter([]), { provide: SignageContextService, useValue: context_stub }, { provide: SignageDisplayService, useValue: display_stub }, { provide: SignagePlaylistService, useValue: playlist_stub }, @@ -47,12 +61,33 @@ describe('DisplayContentComponent', () => { .componentInstance; } + /** Render a tab of the selected display */ + async function render(tab: 'playlists' | 'zones') { + await TestBed.configureTestingModule({ + imports: [DisplayContentComponent], + providers: [ + provideRouter([]), + { provide: SignageContextService, useValue: context_stub }, + { provide: SignageDisplayService, useValue: display_stub }, + { provide: SignagePlaylistService, useValue: playlist_stub }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(DisplayContentComponent); + fixture.componentRef.setInput('activeTab', tab); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + beforeEach(() => { vi.clearAllMocks(); selected_display.set(null); playlists.set([]); selected_display_zones.set([]); playlist_approval_status.set({}); + zones_loading.set(false); + zones_error.set(false); + playlists_loading.set(false); + playlists_error.set(false); }); it('lists the playlists and the queried zones of the display', async () => { @@ -137,4 +172,58 @@ describe('DisplayContentComponent', () => { expect(event.stopPropagation).toHaveBeenCalled(); expect(remove_playlist).toHaveBeenCalledWith(display, 'p1'); }); + + // The empty states use these icons + it('shows that the zones are loading in place of the empty state', async () => { + selected_display.set({ id: 'd1', zones: ['z1'] }); + zones_loading.set(true); + const element = await render('zones'); + + expect(element.querySelector('[role="status"]')).not.toBeNull(); + expect(element.textContent).not.toContain('layers_clear'); + }); + + it('offers a retry when the zones of the display fail to load', async () => { + selected_display.set({ id: 'd1', zones: ['z1'] }); + zones_error.set(true); + const element = await render('zones'); + + expect(element.textContent).not.toContain('layers_clear'); + element.querySelector('load-error button')?.click(); + expect(reload_zones).toHaveBeenCalledTimes(1); + }); + + it('shows that the playlists are loading in place of the empty state', async () => { + selected_display.set({ id: 'd1', playlists: ['p1'] }); + playlists_loading.set(true); + const element = await render('playlists'); + + expect(element.querySelector('[role="status"]')).not.toBeNull(); + expect(element.textContent).not.toContain('playlist_remove'); + }); + + it('offers a retry when the playlists fail to load', async () => { + selected_display.set({ id: 'd1', playlists: ['p1'] }); + playlists_error.set(true); + const element = await render('playlists'); + + expect(element.textContent).not.toContain('playlist_remove'); + element.querySelector('load-error button')?.click(); + expect(reload_playlists).toHaveBeenCalledTimes(1); + }); + + // The shared playlist list does not feed a tab with no playlist ids + it.each(['loading', 'error'] as const)( + 'keeps the empty state of a display with no playlists while the playlist list is in %s', + async (state) => { + selected_display.set({ id: 'd1', playlists: [] }); + playlists_loading.set(state === 'loading'); + playlists_error.set(state === 'error'); + const element = await render('playlists'); + + expect(element.textContent).toContain('playlist_remove'); + expect(element.querySelector('load-error')).toBeNull(); + expect(element.querySelector('[role="status"]')).toBeNull(); + }, + ); }); diff --git a/apps/signage-manager/src/tests/displays/display-list.component.spec.ts b/apps/signage-manager/src/tests/displays/display-list.component.spec.ts index 07f13433b18..e11032a5e29 100644 --- a/apps/signage-manager/src/tests/displays/display-list.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-list.component.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { DisplayListComponent } from '../../app/displays/display-list.component'; import { signageDisplay } from '../../app/displays/signage-display'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; @@ -10,19 +11,24 @@ describe('DisplayListComponent', () => { const selected_display = signal(null); const displays_has_more = signal(false); const displays_loading = signal(false); + const displays_error = signal(false); const load_more = vi.fn(); + const retry_displays = vi.fn(); const display_stub = { display_search_term, filtered_displays, selected_display, displays_has_more, displays_loading, + displays_error, loadMoreDisplays: load_more, + retryDisplays: retry_displays, }; function make() { TestBed.configureTestingModule({ providers: [ + provideRouter([]), { provide: SignageDisplayService, useValue: display_stub }, ], }); @@ -33,6 +39,8 @@ describe('DisplayListComponent', () => { beforeEach(() => { load_more.mockReset(); + retry_displays.mockReset(); + displays_error.set(false); display_search_term.set(''); filtered_displays.set([]); selected_display.set(null); @@ -110,4 +118,28 @@ describe('DisplayListComponent', () => { component.loadMore(); expect(load_more).toHaveBeenCalledTimes(1); }); + + it.each([ + ['no displays loaded', []], + ['some displays loaded', [{ id: 'd1', name: 'Lobby' }]], + ])( + 'offers a retry in place of the list end when a page fails with %s', + (_, items) => { + filtered_displays.set(items); + displays_error.set(true); + make(); + const fixture = TestBed.createComponent(DisplayListComponent); + fixture.detectChanges(); + const element: HTMLElement = fixture.nativeElement; + + expect(element.querySelector('load-error')).not.toBeNull(); + expect(element.textContent).not.toContain('No displays'); + expect(element.textContent).not.toContain('End of list'); + + element + .querySelector('load-error button')! + .click(); + expect(retry_displays).toHaveBeenCalledTimes(1); + }, + ); }); diff --git a/apps/signage-manager/src/tests/displays/display-schedule.component.spec.ts b/apps/signage-manager/src/tests/displays/display-schedule.component.spec.ts index 1a22fbd1e2e..72d2159910d 100644 --- a/apps/signage-manager/src/tests/displays/display-schedule.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-schedule.component.spec.ts @@ -5,26 +5,27 @@ import { addDays, isSameDay, startOfWeek } from 'date-fns'; import { DisplayScheduleComponent } from '../../app/displays/display-schedule.component'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; -import { SignageContextService } from '../../app/signage-context.service'; import { HydratedSignageTemplateMapping } from '../../app/signage-template-mapping'; -import { SignageTemplateService } from '../../app/templates/signage-template.service'; describe('DisplayScheduleComponent', () => { const selected_display = signal(null); const selected_display_zones = signal([]); const playlists = signal([]); - const context_stub = { templates_enabled: signal(false) }; - const display_stub = { selected_display, selected_display_zones }; + const template_mappings = signal([]); + const display_stub = { + selected_display, + selected_display_zones, + selected_display_template_mappings: template_mappings, + selected_display_template_mappings_loading: signal(false), + selected_display_template_mappings_error: signal(false), + }; const playlist_stub = { playlistsById: (ids: readonly string[]) => playlists().filter(({ id }) => ids.includes(id)), }; - const template_stub = { listTemplateMappings: vi.fn() }; const stub_providers = [ - { provide: SignageContextService, useValue: context_stub }, { provide: SignageDisplayService, useValue: display_stub }, { provide: SignagePlaylistService, useValue: playlist_stub }, - { provide: SignageTemplateService, useValue: template_stub }, ]; function make() { @@ -39,8 +40,7 @@ describe('DisplayScheduleComponent', () => { selected_display.set(null); selected_display_zones.set([]); playlists.set([]); - context_stub.templates_enabled.set(false); - template_stub.listTemplateMappings.mockReset().mockResolvedValue([]); + template_mappings.set([]); }); it('renders a full seven-day week starting on the current Monday', () => { @@ -151,8 +151,7 @@ describe('DisplayScheduleComponent', () => { } }); - it('loads display mappings and renders linked playlists inside templates', async () => { - context_stub.templates_enabled.set(true); + it('renders linked playlists inside the templates of the display', async () => { selected_display.set({ id: 'd1', playlists: ['p1'] }); playlists.set([ { @@ -162,7 +161,7 @@ describe('DisplayScheduleComponent', () => { schedules: [{ play_cron: '0 9 * * *', play_period: 60 }], }, ]); - template_stub.listTemplateMappings.mockResolvedValue([ + template_mappings.set([ new HydratedSignageTemplateMapping({ id: 'm1', template_id: 't1', @@ -183,16 +182,10 @@ describe('DisplayScheduleComponent', () => { expect( parent?.querySelector('ul a[href="/playlists/p1"]')?.textContent, ).toContain('Morning playlist'); - expect(template_stub.listTemplateMappings).toHaveBeenCalledWith({ - control_system_id: 'd1', - }); selected_display.set({ id: 'd2', playlists: [] }); - template_stub.listTemplateMappings.mockResolvedValue([]); + template_mappings.set([]); await fixture.whenStable(); - expect(template_stub.listTemplateMappings).toHaveBeenLastCalledWith({ - control_system_id: 'd2', - }); expect(element.querySelector('a[href="/templates/t1"]')).toBeNull(); }); diff --git a/apps/signage-manager/src/tests/displays/displays.component.spec.ts b/apps/signage-manager/src/tests/displays/displays.component.spec.ts index 470b9c215c9..49ac6cf0f74 100644 --- a/apps/signage-manager/src/tests/displays/displays.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/displays.component.spec.ts @@ -1,13 +1,14 @@ import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; +import { setNotifyOutlet } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; import { PlaceSystem, show } from '@placeos/ts-client'; import { DisplaysSectionComponent } from '../../app/displays/displays.component'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; -import { SignageTemplateService } from '../../app/templates/signage-template.service'; vi.mock('@placeos/ts-client', { spy: true }); @@ -21,8 +22,8 @@ describe('DisplaysSectionComponent', () => { const templates_enabled = signal(true); const playlists_loading = signal(false); const related_loading = signal(false); - const template_mappings_revision = signal(0); - const list_template_mappings = vi.fn(); + const template_mappings = signal<{ id: string }[]>([]); + const template_mappings_loading = signal(false); const navigate = vi.fn(); const edit_display = vi.fn(); const remove_display = vi.fn(); @@ -36,6 +37,8 @@ describe('DisplaysSectionComponent', () => { displays, selected_display_zones, selected_display_zones_loading: related_loading, + selected_display_template_mappings: template_mappings, + selected_display_template_mappings_loading: template_mappings_loading, editDisplay: edit_display, removeDisplay: remove_display, }; @@ -44,10 +47,6 @@ describe('DisplaysSectionComponent', () => { playlists().filter(({ id }) => ids.includes(id)), playlists_loading, }; - const template_stub = { - template_mappings_revision, - listTemplateMappings: list_template_mappings, - }; const router_stub = { navigate }; async function make( @@ -61,7 +60,6 @@ describe('DisplaysSectionComponent', () => { { provide: SignageContextService, useValue: context_stub }, { provide: SignageDisplayService, useValue: display_stub }, { provide: SignagePlaylistService, useValue: playlist_stub }, - { provide: SignageTemplateService, useValue: template_stub }, { provide: Router, useValue: router_stub }, { provide: ActivatedRoute, useValue: {} }, ], @@ -87,19 +85,14 @@ describe('DisplaysSectionComponent', () => { templates_enabled.set(true); playlists_loading.set(false); related_loading.set(false); - template_mappings_revision.set(0); - list_template_mappings.mockResolvedValue([]); + template_mappings.set([]); + template_mappings_loading.set(false); remove_display.mockResolvedValue(false); }); it('shows question marks in count badges while data loads', async () => { selected_display.set({ id: 'target-1' }); - let finish_loading!: (value: []) => void; - list_template_mappings.mockReturnValue( - new Promise<[]>((resolve) => { - finish_loading = resolve; - }), - ); + template_mappings_loading.set(true); playlists_loading.set(true); related_loading.set(true); const [, fixture] = await make(true); @@ -114,7 +107,7 @@ describe('DisplaysSectionComponent', () => { } }); - finish_loading([]); + template_mappings_loading.set(false); playlists_loading.set(false); related_loading.set(false); await fixture.whenStable(); @@ -124,34 +117,16 @@ describe('DisplaysSectionComponent', () => { } }); - it('counts template mappings and refreshes after assignment changes', async () => { + it('counts the template mappings of the selected display', async () => { selected_display.set({ id: 'target-1' }); - list_template_mappings.mockResolvedValue([{ id: 'm1' }, { id: 'm2' }]); + template_mappings.set([{ id: 'm1' }, { id: 'm2' }]); const [component, fixture] = await make(true); await fixture.whenStable(); - expect(list_template_mappings).toHaveBeenCalledWith({ - control_system_id: 'target-1', - }); expect(component.template_count()).toBe(2); const element: HTMLElement = fixture.nativeElement; const tab = element.querySelector('#display-templates-tab span'); expect(tab?.textContent?.trim()).toBe('2'); - - list_template_mappings.mockResolvedValue([{ id: 'm1' }]); - template_mappings_revision.update((value) => value + 1); - await fixture.whenStable(); - expect(component.template_count()).toBe(1); - expect(tab?.textContent?.trim()).toBe('1'); - - selected_display.set({ id: 'target-2' }); - list_template_mappings.mockResolvedValue([]); - await fixture.whenStable(); - expect(list_template_mappings).toHaveBeenLastCalledWith({ - control_system_id: 'target-2', - }); - expect(component.template_count()).toBe(0); - expect(tab?.textContent?.trim()).toBe('0'); }); it('counts the playlists and zones attached to the selected display', async () => { @@ -207,6 +182,49 @@ describe('DisplaysSectionComponent', () => { ); }); + it('loads a linked display again after going back to it', async () => { + displays.set([{ id: 'd1' }]); + const far = new PlaceSystem({ id: 'd-far', name: 'Far' }); + vi.mocked(show).mockResolvedValue(far); + const [, fixture] = await make(); + const route = (id: string) => { + fixture.componentRef.setInput('id', id); + fixture.detectChanges(); + TestBed.flushEffects(); + }; + + route('d-far'); + await vi.waitFor(() => expect(selected_display()).toBe(far)); + route('d1'); + expect(selected_display()?.id).toBe('d1'); + route('d-far'); + + await vi.waitFor(() => expect(selected_display()).toBe(far)); + expect(show).toHaveBeenCalledTimes(2); + }); + + it('shows an error when a linked display cannot be loaded', async () => { + const notify_open = vi.fn(() => ({ + onAction: () => ({ subscribe: () => ({ unsubscribe: () => {} }) }), + dismiss: vi.fn(), + })); + setNotifyOutlet({ open: notify_open } as unknown as MatSnackBar, true); + vi.mocked(show).mockRejectedValue(new Error('Not found')); + const [, fixture] = await make(); + fixture.componentRef.setInput('id', 'd-gone'); + fixture.detectChanges(); + TestBed.flushEffects(); + + await vi.waitFor(() => + expect(notify_open).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + expect.objectContaining({ panelClass: ['error'] }), + ), + ); + expect(selected_display()).toBeNull(); + }); + it('clears the selection when navigating back to the list', async () => { displays.set([{ id: 'd1' }]); const [, fixture] = await make(); diff --git a/apps/signage-manager/src/tests/displays/signage-display.service.spec.ts b/apps/signage-manager/src/tests/displays/signage-display.service.spec.ts index b63d36e8fa6..4098e2b5844 100644 --- a/apps/signage-manager/src/tests/displays/signage-display.service.spec.ts +++ b/apps/signage-manager/src/tests/displays/signage-display.service.spec.ts @@ -6,9 +6,18 @@ import { setNotifyOutlet, SettingsService, } from '@placeos/common'; -import { PlaceSystem, show, SignagePlaylist, update } from '@placeos/ts-client'; +import { + PlaceSystem, + removeSystem, + show, + SignagePlaylist, + update, +} from '@placeos/ts-client'; +import { NEVER, of } from 'rxjs'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignageContextService } from '../../app/signage-context.service'; +import { HydratedSignageTemplateMapping } from '../../app/signage-template-mapping'; +import { SignageTemplateService } from '../../app/templates/signage-template.service'; vi.mock('@placeos/ts-client', { spy: true }); @@ -149,4 +158,88 @@ describe('SignageDisplayService', () => { expect(update).not.toHaveBeenCalled(); expect(changed).not.toHaveBeenCalled(); }); + + /** The next confirm modal returns "done" */ + function confirmNextDialog() { + dialog.open.mockReturnValue({ + componentInstance: { + event: of({ reason: 'done' }), + loading: { set: vi.fn() }, + }, + afterClosed: () => NEVER, + close: vi.fn(), + }); + } + + // Zone, schedule and report views keep their own copies of displays + it('reloads the other views after a display is saved or removed', async () => { + const service = createService(); + const changed = vi.spyOn( + TestBed.inject(SignageContextService), + 'changed', + ); + closeNextDialogWith(new PlaceSystem({ id: 'd1', name: 'Lobby' })); + + await service.editDisplay(new PlaceSystem({ id: 'd1' })); + expect(changed).toHaveBeenCalledTimes(1); + + vi.mocked(removeSystem).mockResolvedValue({}); + confirmNextDialog(); + await service.removeDisplay(new PlaceSystem({ id: 'd1' })); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('takes a display used outside signage off signage instead of deleting it', async () => { + const service = createService(); + const display = new PlaceSystem({ + id: 'room-1', + version: 4, + modules: ['mod-1'], + }); + vi.mocked(update).mockResolvedValue(display); + confirmNextDialog(); + + expect(await service.removeDisplay(display)).toBe(true); + + expect(removeSystem).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'room-1', + form_data: { signage: false }, + method: 'patch', + }), + ); + }); + + it('loads the template mappings of the selected display once for its views', async () => { + vi.spyOn( + TestBed.inject(SignageContextService), + 'hasFeature', + ).mockReturnValue(true); + const templates = TestBed.inject(SignageTemplateService); + const list = vi + .spyOn(templates, 'listTemplateMappings') + .mockResolvedValue([ + new HydratedSignageTemplateMapping({ id: 'm1' }), + ]); + const service = createService(); + + service.selected_display.set(new PlaceSystem({ id: 'd1' })); + await vi.waitFor(() => + expect(service.selected_display_template_mappings()).toHaveLength( + 1, + ), + ); + expect(list).toHaveBeenCalledExactlyOnceWith({ + control_system_id: 'd1', + }); + + list.mockResolvedValue([]); + templates.template_mappings_revision.update((value) => value + 1); + TestBed.tick(); + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect(service.selected_display_template_mappings()).toEqual([]), + ); + }); }); diff --git a/apps/signage-manager/src/tests/signage-display-search.spec.ts b/apps/signage-manager/src/tests/signage-display-search.spec.ts index b49835e8352..e2fccaadeb6 100644 --- a/apps/signage-manager/src/tests/signage-display-search.spec.ts +++ b/apps/signage-manager/src/tests/signage-display-search.spec.ts @@ -116,6 +116,24 @@ describe('SignageDisplayService display search', () => { ]); }); + it('loads the first page again after it failed', async () => { + mockDisplayList(pageOf(['lobby'], 1)); + vi.mocked(query).mockRejectedValueOnce(new Error('offline')); + const service = TestBed.inject(SignageDisplayService); + TestBed.tick(); + await flush(); + expect(service.displays_error()).toBe(true); + + service.retryDisplays(); + TestBed.tick(); + await flush(); + + expect(service.displays_error()).toBe(false); + expect(service.filtered_displays().map(({ id }) => id)).toEqual([ + 'lobby', + ]); + }); + it('should page the search results', async () => { const service = await init(); mockDisplayList(pageOf(['lobby-1'], 2, pageOf(['lobby-2'], 2))); diff --git a/apps/signage-manager/src/tests/signage-displays-zones.spec.ts b/apps/signage-manager/src/tests/signage-displays-zones.spec.ts index 3c11d232d83..764d2178029 100644 --- a/apps/signage-manager/src/tests/signage-displays-zones.spec.ts +++ b/apps/signage-manager/src/tests/signage-displays-zones.spec.ts @@ -205,7 +205,10 @@ describe('SignageDisplayService and SignageZoneService', () => { d1: new PlaceSystem({ id: 'd1', name: 'Edited' }), other: new PlaceSystem({ id: 'other', name: 'Other group' }), }); - zones['_all_zone_list'].set([new PlaceZone({ id: 'z1' })]); + zones['_all_zone_list'].set({ + zones: [new PlaceZone({ id: 'z1' })], + total: 1, + }); zones['_zone_overrides'].set({ other: new PlaceZone({ id: 'other', name: 'Other group' }), }); diff --git a/apps/signage-manager/src/tests/signage-zone-search.spec.ts b/apps/signage-manager/src/tests/signage-zone-search.spec.ts index 276903d73d9..525b68deb46 100644 --- a/apps/signage-manager/src/tests/signage-zone-search.spec.ts +++ b/apps/signage-manager/src/tests/signage-zone-search.spec.ts @@ -75,6 +75,75 @@ describe('SignageZoneService zone search', () => { ]); }); + it('reports a failed zone load and loads it again on retry', async () => { + vi.mocked(queryZones).mockRejectedValue(new Error('offline')); + const service = TestBed.inject(SignageZoneService); + TestBed.tick(); + await flush(); + expect(service.zones_error()).toBe(true); + expect(service.all_zones()).toEqual([]); + + vi.mocked(queryZones).mockResolvedValue({ + data: [new PlaceZone({ id: 'z1', tags: ['signage'] })], + total: 7, + next: null, + }); + service.reloadZones(); + TestBed.tick(); + await flush(); + + expect(service.zones_error()).toBe(false); + expect(service.all_zones().map(({ id }) => id)).toEqual(['z1']); + // The header count reads the signage zone list, so it reloads too + expect(service.signage_zone_count()).toBe(7); + }); + + it('counts signage zones from the server total', async () => { + vi.mocked(queryZones).mockResolvedValue({ + data: [new PlaceZone({ id: 'z1', tags: ['signage'] })], + total: 7, + next: null, + }); + const service = TestBed.inject(SignageZoneService); + TestBed.tick(); + await flush(); + + expect(service.signage_zone_count()).toBe(7); + expect(queryZones).toHaveBeenCalledWith( + expect.objectContaining({ tags: 'signage' }), + ); + }); + + // The tree loads from other lists, so it can look complete without it + it('reports a failed signage zone count and loads it again on retry', async () => { + let fail_count = true; + vi.mocked(queryZones).mockImplementation(async (params) => { + if (params?.tags === 'signage' && fail_count) { + throw new Error('offline'); + } + return { + data: [new PlaceZone({ id: 'z1', tags: ['signage'] })], + total: 7, + next: null, + }; + }); + const service = TestBed.inject(SignageZoneService); + TestBed.tick(); + await flush(); + + expect(service.all_zones().map(({ id }) => id)).toEqual(['z1']); + expect(service.signage_zone_count()).toBeNull(); + expect(service.zones_error()).toBe(true); + + fail_count = false; + service.reloadZones(); + TestBed.tick(); + await flush(); + + expect(service.signage_zone_count()).toBe(7); + expect(service.zones_error()).toBe(false); + }); + it('searches selectable zones beneath the selected zone', () => { const service = TestBed.inject(SignageZoneService); diff --git a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts index 89ad317b893..e4c48db2c30 100644 --- a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; @@ -18,9 +19,18 @@ describe('ZoneContentComponent', () => { const add_playlist = vi.fn(); const remove_playlist = vi.fn(); const add_display = vi.fn(); + const displays_loading = signal(false); + const displays_error = signal(false); + const playlists_loading = signal(false); + const playlists_error = signal(false); + const reload_playlists = vi.fn(); + const reload_displays = vi.fn(); const context_stub = { can_update }; const display_stub = { selected_zone_displays, + selected_zone_displays_loading: displays_loading, + selected_zone_displays_error: displays_error, + reloadSelectedZoneDisplays: reload_displays, addDisplayToZone: add_display, }; const playlist_stub = { @@ -28,6 +38,9 @@ describe('ZoneContentComponent', () => { playlists().filter(({ id }) => ids.includes(id)), playlist_approval_status, playlist_thumbnail_media, + playlists_loading, + playlists_error, + reloadPlaylists: reload_playlists, }; const zone_stub = { selected_zone, @@ -52,12 +65,36 @@ describe('ZoneContentComponent', () => { return TestBed.createComponent(ZoneContentComponent).componentInstance; } + /** Render a tab of the selected zone */ + async function render(tab: 'playlists' | 'displays') { + await TestBed.configureTestingModule({ + imports: [ZoneContentComponent], + providers: [ + provideRouter([]), + { provide: SignageContextService, useValue: context_stub }, + { provide: SignageDisplayService, useValue: display_stub }, + { provide: SignagePlaylistService, useValue: playlist_stub }, + { provide: SignageZoneService, useValue: zone_stub }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ZoneContentComponent); + fixture.componentRef.setInput('activeTab', tab); + fixture.detectChanges(); + return fixture.nativeElement.querySelector( + `#zone-${tab}-panel`, + ) as HTMLElement; + } + beforeEach(() => { vi.clearAllMocks(); selected_zone.set(null); playlists.set([]); selected_zone_displays.set([]); playlist_approval_status.set({}); + displays_loading.set(false); + displays_error.set(false); + playlists_loading.set(false); + playlists_error.set(false); }); it('lists the playlists and the queried displays of the zone', async () => { @@ -112,4 +149,58 @@ describe('ZoneContentComponent', () => { expect(remove_playlist).toHaveBeenCalledWith(zone, 'p1'); expect(event.preventDefault).toHaveBeenCalled(); }); + + // The empty states use these icons + it('shows that the displays are loading in place of the empty state', async () => { + selected_zone.set({ id: 'z1' }); + displays_loading.set(true); + const panel = await render('displays'); + + expect(panel.querySelector('[role="status"]')).not.toBeNull(); + expect(panel.textContent).not.toContain('tv_off'); + }); + + it('offers a retry when the displays of the zone fail to load', async () => { + selected_zone.set({ id: 'z1' }); + displays_error.set(true); + const panel = await render('displays'); + + expect(panel.textContent).not.toContain('tv_off'); + panel.querySelector('load-error button')?.click(); + expect(reload_displays).toHaveBeenCalledTimes(1); + }); + + it('shows that the playlists are loading in place of the empty state', async () => { + selected_zone.set({ id: 'z1', playlists: ['p1'] }); + playlists_loading.set(true); + const panel = await render('playlists'); + + expect(panel.querySelector('[role="status"]')).not.toBeNull(); + expect(panel.textContent).not.toContain('playlist_remove'); + }); + + it('offers a retry when the playlists fail to load', async () => { + selected_zone.set({ id: 'z1', playlists: ['p1'] }); + playlists_error.set(true); + const panel = await render('playlists'); + + expect(panel.textContent).not.toContain('playlist_remove'); + panel.querySelector('load-error button')?.click(); + expect(reload_playlists).toHaveBeenCalledTimes(1); + }); + + // The shared playlist list does not feed a tab with no playlist ids + it.each(['loading', 'error'] as const)( + 'keeps the empty state of a zone with no playlists while the playlist list is in %s', + async (state) => { + selected_zone.set({ id: 'z1', playlists: [] }); + playlists_loading.set(state === 'loading'); + playlists_error.set(state === 'error'); + const panel = await render('playlists'); + + expect(panel.textContent).toContain('playlist_remove'); + expect(panel.querySelector('load-error')).toBeNull(); + expect(panel.querySelector('[role="status"]')).toBeNull(); + }, + ); }); diff --git a/apps/signage-manager/src/tests/zones/zone-header.component.spec.ts b/apps/signage-manager/src/tests/zones/zone-header.component.spec.ts index f04f7dac202..49845360374 100644 --- a/apps/signage-manager/src/tests/zones/zone-header.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zone-header.component.spec.ts @@ -6,13 +6,19 @@ import { SignageZoneService } from '../../app/zones/signage-zone.service'; import { ZoneHeaderComponent } from '../../app/zones/zone-header.component'; describe('ZoneHeaderComponent', () => { - const filtered_zones = signal([]); + const filtered_zones = signal<{ id: string }[]>([]); + const signage_zone_count = signal(0); + const selected_zone = signal<{ id: string } | null>(null); + const zone_search_term = signal(''); const can_manage_zones = signal(false); const add_zone = vi.fn(); const navigate = vi.fn(); const context_stub = { can_manage_zones }; const zone_stub = { filtered_zones, + signage_zone_count, + selected_zone, + zone_search_term, addZone: add_zone, }; @@ -30,15 +36,29 @@ describe('ZoneHeaderComponent', () => { beforeEach(() => { vi.clearAllMocks(); filtered_zones.set([]); + signage_zone_count.set(0); + selected_zone.set(null); + zone_search_term.set(''); can_manage_zones.set(false); add_zone.mockResolvedValue(null); }); - it('reports the number of filtered zones', () => { + // The tree also lists untagged parents, so it is not the zone count + it('reports the server total of signage zones', () => { const component = make(); - expect(component.total_count()).toBe(0); + filtered_zones.set([{ id: 'org' }, { id: 'building' }, { id: 'a' }]); + signage_zone_count.set(1); + expect(component.total_count()).toBe(1); + }); + + it('reports the number of search results while searching a zone', () => { + const component = make(); + signage_zone_count.set(40); + selected_zone.set({ id: 'building' }); + zone_search_term.set('lobby'); filtered_zones.set([{ id: 'a' }, { id: 'b' }]); + expect(component.total_count()).toBe(2); }); diff --git a/apps/signage-manager/src/tests/zones/zone-list.component.spec.ts b/apps/signage-manager/src/tests/zones/zone-list.component.spec.ts index 220a6c03949..aa2c7aa3460 100644 --- a/apps/signage-manager/src/tests/zones/zone-list.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zone-list.component.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { OrganisationService } from '@placeos/common'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; import { ZoneListComponent } from '../../app/zones/zone-list.component'; @@ -14,6 +15,9 @@ describe('ZoneListComponent', () => { const zone_tree_expanded = signal>({}); const zone_tree_children_cache = signal>({}); const zone_children = vi.fn(); + const zones_loading = signal(false); + const zones_error = signal(false); + const reload_zones = vi.fn(); const org_stub = { initialised }; const zone_stub = { all_zones, @@ -24,22 +28,29 @@ describe('ZoneListComponent', () => { zone_tree_expanded, zone_tree_children_cache, zoneChildren: zone_children, + zones_loading, + zones_error, + reloadZones: reload_zones, }; - async function make() { + async function make(render_template = false) { await TestBed.configureTestingModule({ imports: [ZoneListComponent], providers: [ + provideRouter([]), { provide: SignageZoneService, useValue: zone_stub }, { provide: OrganisationService, useValue: org_stub }, ], }) - .overrideComponent(ZoneListComponent, { set: { template: '' } }) + .overrideComponent( + ZoneListComponent, + render_template ? {} : { set: { template: '' } }, + ) .compileComponents(); const fixture = TestBed.createComponent(ZoneListComponent); fixture.detectChanges(); TestBed.flushEffects(); - return fixture.componentInstance; + return fixture; } beforeEach(() => { @@ -53,10 +64,39 @@ describe('ZoneListComponent', () => { zone_tree_expanded.set({}); zone_tree_children_cache.set({}); zone_children.mockResolvedValue([]); + zones_loading.set(false); + zones_error.set(false); + }); + + it('shows that the zones are loading in place of the empty state', async () => { + zones_loading.set(true); + const element: HTMLElement = (await make(true)).nativeElement; + + expect(element.querySelector('[role="status"]')).not.toBeNull(); + expect(element.textContent).not.toContain('No zones'); + }); + + it('offers a retry when the zones fail to load', async () => { + zones_error.set(true); + const element: HTMLElement = (await make(true)).nativeElement; + + expect(element.textContent).not.toContain('No zones'); + element.querySelector('load-error button')?.click(); + expect(reload_zones).toHaveBeenCalledTimes(1); + }); + + it('offers a retry beside the zones that loaded when a list fails', async () => { + root_zones.set([{ id: 'org-1', name: 'Organisation' }]); + zones_error.set(true); + const element: HTMLElement = (await make(true)).nativeElement; + + expect(element.textContent).toContain('Organisation'); + element.querySelector('load-error button')?.click(); + expect(reload_zones).toHaveBeenCalledTimes(1); }); it('only enables search after selecting a zone', async () => { - const component = await make(); + const component = (await make()).componentInstance; expect(component.search_enabled()).toBe(false); expect(component.show_search_results()).toBe(false); @@ -72,7 +112,7 @@ describe('ZoneListComponent', () => { selected_zone.set({ id: 'r1' }); zone_search_term.set('lobby'); filtered_zones.set([{ id: 'z1', parent_id: 'r1', children_count: 2 }]); - const component = await make(); + const component = (await make()).componentInstance; expect( component @@ -95,7 +135,7 @@ describe('ZoneListComponent', () => { { id: 'c2', parent_id: 'r1' }, { id: 'gc1', parent_id: 'c1' }, ]); - const component = await make(); + const component = (await make()).componentInstance; expect(component.child_count_lookup()).toEqual({ r1: 2, c1: 1 }); expect(component.children_lookup()['r1'].map((z: any) => z.id)).toEqual( @@ -105,14 +145,14 @@ describe('ZoneListComponent', () => { it('reports child count from the lookup for a known parent zone', async () => { all_zones.set([{ id: 'r1' }, { id: 'c1', parent_id: 'r1' }]); - const component = await make(); + const component = (await make()).componentInstance; expect(component.childCount('r1')).toBe(1); expect(component.childCount('c1')).toBe(0); }); it('falls back to a zone children_count when it has no lookup entry', async () => { - const component = await make(); + const component = (await make()).componentInstance; expect( component.childCount({ id: 'x', children_count: 4 } as any), ).toBe(4); @@ -121,7 +161,7 @@ describe('ZoneListComponent', () => { it('builds root tree nodes from the service root zones', async () => { root_zones.set([{ id: 'r1', name: 'Root 1' }]); all_zones.set([{ id: 'r1' }, { id: 'c1', parent_id: 'r1' }]); - const component = await make(); + const component = (await make()).componentInstance; const flat = component.flat_tree_nodes(); expect(flat.map((n) => n.zone.id)).toEqual(['r1']); @@ -130,7 +170,7 @@ describe('ZoneListComponent', () => { it('selects a zone through the shared selection signal', async () => { zone_search_term.set('lobby'); - const component = await make(); + const component = (await make()).componentInstance; component.selectZone({ id: 'z9' } as any); expect(selected_zone()?.id).toBe('z9'); expect(zone_search_term()).toBe(''); @@ -140,7 +180,7 @@ describe('ZoneListComponent', () => { root_zones.set([{ id: 'r1', name: 'Root 1', children_count: 1 }]); all_zones.set([{ id: 'r1' }, { id: 'c1', parent_id: 'r1' }]); zone_children.mockResolvedValue([{ id: 'c1', parent_id: 'r1' }]); - const component = await make(); + const component = (await make()).componentInstance; const node = component.tree_nodes()[0]; expect(component.isExpanded(node)).toBe(true); @@ -150,7 +190,7 @@ describe('ZoneListComponent', () => { it('keeps a node unloaded with a retry when its children fail to load', async () => { root_zones.set([{ id: 'r1', name: 'Root 1', children_count: 1 }]); zone_children.mockRejectedValueOnce(new Error('Network')); - const component = await make(); + const component = (await make()).componentInstance; await vi.waitFor(() => expect(component.tree_nodes()[0].children_error).toBe(true), ); @@ -177,7 +217,7 @@ describe('ZoneListComponent', () => { { id: 'a', parent_id: 'b' }, { id: 'b', parent_id: 'a' }, ]); - const component = await make(); + const component = (await make()).componentInstance; expect(component['getZonePath']('a')).toEqual([]); }); @@ -185,7 +225,7 @@ describe('ZoneListComponent', () => { it('allows the automatically expanded root to be collapsed', async () => { root_zones.set([{ id: 'r1', name: 'Root 1', children_count: 1 }]); all_zones.set([{ id: 'r1' }, { id: 'c1', parent_id: 'r1' }]); - const component = await make(); + const component = (await make()).componentInstance; const node = component.tree_nodes()[0]; component.onExpandedChange(node, false); diff --git a/apps/signage-manager/src/tests/zones/zones.component.spec.ts b/apps/signage-manager/src/tests/zones/zones.component.spec.ts index 125ffcfd7cf..6eb377a8b81 100644 --- a/apps/signage-manager/src/tests/zones/zones.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zones.component.spec.ts @@ -187,6 +187,28 @@ describe('ZonesSectionComponent', () => { expect(showZone).toHaveBeenCalledExactlyOnceWith('z-far'); }); + it('loads a linked zone again after going back to it', async () => { + all_zones.set([{ id: 'z1' }]); + vi.mocked(showZone).mockResolvedValue( + new PlaceZone({ id: 'z-far', name: 'Far' }), + ); + const [, fixture] = await make(); + const route = (id: string) => { + fixture.componentRef.setInput('id', id); + fixture.detectChanges(); + TestBed.flushEffects(); + }; + + route('z-far'); + await vi.waitFor(() => expect(selected_zone()?.id).toBe('z-far')); + route('z1'); + expect(selected_zone()?.id).toBe('z1'); + route('z-far'); + + await vi.waitFor(() => expect(selected_zone()?.id).toBe('z-far')); + expect(showZone).toHaveBeenCalledTimes(2); + }); + it('clears the selection when navigating back to the list', async () => { all_zones.set([{ id: 'z1' }]); const [, fixture] = await make();