Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/signage-manager/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

---

Expand All @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -215,6 +219,21 @@ type PlaylistStatus = 'expired' | 'pending' | 'awaiting_approval' | null;
}
</div>
}
} @else if (
has_assigned_playlists() &&
playlists_loading()
) {
<div
class="text-base-content/70 p-6 text-center"
role="status"
>
{{ 'COMMON.LOADING' | translate }}
</div>
} @else if (
has_assigned_playlists() &&
playlists_error()
) {
<load-error (retry)="reloadPlaylists()" />
} @else {
<div
class="text-base-content/70 flex flex-col items-center justify-center space-y-2 p-6"
Expand Down Expand Up @@ -303,6 +322,15 @@ type PlaylistStatus = 'expired' | 'pending' | 'awaiting_approval' | null;
</div>
</a>
}
} @else if (display_zones_loading()) {
<div
class="text-base-content/70 p-6 text-center"
role="status"
>
{{ 'COMMON.LOADING' | translate }}
</div>
} @else if (display_zones_error()) {
<load-error (retry)="reloadZones()" />
} @else {
<div
class="text-base-content/70 flex flex-col items-center justify-center space-y-2 p-6"
Expand Down Expand Up @@ -348,6 +376,7 @@ type PlaylistStatus = 'expired' | 'pending' | 'awaiting_approval' | null;
MatTooltipModule,
RouterLink,
IconComponent,
LoadErrorComponent,
DisplayScheduleComponent,
TemplateMappingsComponent,
TranslatePipe,
Expand All @@ -372,8 +401,29 @@ export class DisplayContentComponent {
this.selected_display()?.playlists || [],
),
);
public readonly playlists_loading =
this._playlist_service.playlists_loading;
public readonly playlists_error = this._playlist_service.playlists_error;
// The tab reads the playlist ids of the display and resolves them from the
// shared playlist list. The list state matters only when there are ids.
public readonly has_assigned_playlists = computed(
() => !!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();
Expand Down
14 changes: 12 additions & 2 deletions apps/signage-manager/src/app/displays/display-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { RouterLink } from '@angular/router';
import {
DateFromPipe,
IconComponent,
LoadErrorComponent,
TranslatePipe,
} from '@placeos/components';
import { isSameDay } from 'date-fns';
Expand All @@ -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
"
Expand Down Expand Up @@ -119,6 +119,8 @@ import { SignageDisplayService } from './signage-display.service';
intersect
(intersect)="loadMore()"
></div>
} @else if (error()) {
<load-error (retry)="retry()" />
} @else if (!loading()) {
<div class="text-base-content/50 p-3 text-center text-xs">
{{ 'COMMON.END_OF_LIST' | translate }}
Expand All @@ -131,6 +133,8 @@ import { SignageDisplayService } from './signage-display.service';
>
{{ 'COMMON.LOADING' | translate }}
</div>
} @else if (error()) {
<load-error (retry)="retry()" />
} @else {
<div
class="text-base-content/70 flex flex-1 flex-col items-center justify-center space-y-2 p-8"
Expand Down Expand Up @@ -158,6 +162,7 @@ import { SignageDisplayService } from './signage-display.service';
MatInputModule,
MatTooltipModule,
IconComponent,
LoadErrorComponent,
TranslatePipe,
IntersectDirective,
],
Expand All @@ -174,6 +179,7 @@ export class DisplayListComponent {
// Backend pagination: fetches the next page as the sentinel scrolls in.
public readonly has_more = this._display_service.displays_has_more;
public readonly loading = this._display_service.displays_loading;
public readonly error = this._display_service.displays_error;

// Ticks each minute so a display that stops checking in turns offline
// without a reload.
Expand Down Expand Up @@ -208,6 +214,10 @@ export class DisplayListComponent {
this._display_service.loadMoreDisplays();
}

public retry() {
this._display_service.retryDisplays();
}

public isOnline(display: { signage_last_seen?: number }) {
return isDisplayOnline(display.signage_last_seen, this._now());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject, resource, signal } from '@angular/core';
import { Component, computed, inject, signal } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
Expand All @@ -12,8 +12,6 @@ import {
buildDisplayScheduleAssignments,
buildScheduleBlocks,
} from '../schedules/signage-schedule.util';
import { SignageContextService } from '../signage-context.service';
import { SignageTemplateService } from '../templates/signage-template.service';
import { buildDisplayScheduleDays } from './display-schedule.util';
import { SignageDisplayService } from './signage-display.service';

Expand Down Expand Up @@ -324,10 +322,8 @@ import { SignageDisplayService } from './signage-display.service';
],
})
export class DisplayScheduleComponent {
private readonly _context = inject(SignageContextService);
private readonly _display_service = inject(SignageDisplayService);
private readonly _playlist_service = inject(SignagePlaylistService);
private readonly _template_service = inject(SignageTemplateService);

public readonly selected_display = this._display_service.selected_display;
private readonly _zones = this._display_service.selected_display_zones;
Expand Down Expand Up @@ -362,23 +358,12 @@ export class DisplayScheduleComponent {
return buildScheduleBlocks(this.display_assignments(), days);
});

private readonly _template_mappings = resource({
params: () =>
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(
Expand Down
80 changes: 11 additions & 69 deletions apps/signage-manager/src/app/displays/displays.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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);

Expand All @@ -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(
Expand All @@ -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());
Expand All @@ -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'], {});
Expand Down
Loading
Loading