({
path: 'signage/template_mappings',
query_params: { ...query_params, limit: 10_000 },
- fn: (data) => new HydratedSignageTemplateMapping(data),
+ fn: (data) =>
+ new HydratedSignageTemplateMapping({
+ ...data,
+ template_details: decodeEntityNames(data.template_details),
+ }),
});
return result.data;
}
@@ -235,7 +339,17 @@ export class SignageTemplateService {
)
)
return false;
- const templates = mapping ? [] : await this.listApprovedTemplates();
+ if (this.template_mapping_opening()) return false;
+ let templates: SignageTemplate[] = [];
+ this.template_mapping_opening.set(true);
+ try {
+ if (!mapping) templates = await this.listApprovedTemplates();
+ } catch {
+ notifyError(i18n('COMMON.LOAD_ERROR'));
+ return false;
+ } finally {
+ this.template_mapping_opening.set(false);
+ }
const { TemplateMappingModalComponent } =
await import('../shared/template-mapping-modal.component');
const ref = this._dialog.open(TemplateMappingModalComponent, {
@@ -293,12 +407,12 @@ export class SignageTemplateService {
result.close();
notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVED'));
return true;
- } catch (error) {
+ } catch {
result.close();
notifyError(
i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVE_ERROR'),
);
- throw error;
+ return false;
}
}
@@ -374,15 +488,19 @@ export class SignageTemplateService {
notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUESTED'));
}
+ /**
+ * Delete a template after confirmation.
+ * @returns Whether the template was deleted
+ */
public async removeTemplate(template: SignageTemplate) {
- if (!template?.id) return;
+ if (!template?.id) return false;
if (
!this._context.requirePermission(
this._context.can_delete_templates(),
'SIGNAGE_MANAGER.SVC_NO_DELETE_TEMPLATES',
)
)
- return;
+ return false;
const result = await openConfirmModal(
{
title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_TEMPLATE_TITLE'),
@@ -393,7 +511,7 @@ export class SignageTemplateService {
},
this._dialog,
);
- if (result.reason !== 'done') return;
+ if (result.reason !== 'done') return false;
try {
await removeSignageTemplate(
template.id,
@@ -402,8 +520,14 @@ export class SignageTemplateService {
} catch {
result.close();
notifyError(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVE_ERROR'));
- return;
+ return false;
}
+ this._releaseDraft(template.id);
+ // The reload keeps the loaded rows on screen, so drop this one now
+ this._template_list.update((items) =>
+ items.filter((item) => !isSameSignageTemplate(item, template)),
+ );
+ this._template_list.adjustTotal(-1);
if (this.selected_template()?.id === template.id) {
this.selected_template.set(null);
this.selected_template_layout_index.set(null);
@@ -411,6 +535,7 @@ export class SignageTemplateService {
this._context.changed();
notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVED'));
result.close();
+ return true;
}
/**
@@ -493,7 +618,7 @@ export class SignageTemplateService {
/**
* Discard the pending draft of a template and restore its previous
- * version. Used by the approval modals.
+ * version, after the user confirms. Used by the approval modals.
* @returns Whether the draft was discarded
*/
public async undoTemplateChanges(
@@ -507,11 +632,32 @@ export class SignageTemplateService {
)
)
return false;
+ const result = await openConfirmModal(
+ {
+ title: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'),
+ content: i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_CONFIRM', {
+ name: previous_version.name,
+ }),
+ confirm_text: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'),
+ icon: { content: 'undo' },
+ },
+ this._dialog,
+ );
+ if (result.reason !== 'done') return false;
+ result.loading(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES'));
+ // Keep the confirmation open until the draft is gone. Closing it
+ // would not stop the request.
+ const confirm_ref = this._dialog.openDialogs.at(-1);
+ if (confirm_ref?.componentInstance instanceof ConfirmModalComponent) {
+ confirm_ref.disableClose = true;
+ }
try {
await removeSignageTemplateDraft(template_id);
} catch {
notifyError(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_ERROR'));
return false;
+ } finally {
+ result.close();
}
this.updateCachedTemplate(previous_version);
notifySuccess(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERTED'));
@@ -537,7 +683,10 @@ export class SignageTemplateService {
);
}
- public updateCachedTemplate(template: SignageTemplate) {
+ /** Replace the loaded copies of a template. Stores it under its live ID. */
+ public updateCachedTemplate(changed: SignageTemplate) {
+ const template = liveSignageTemplate(changed);
+ this._holdDraft(template);
this._template_list.update((items) =>
items.map((item) =>
isSameSignageTemplate(item, template) ? template : item,
@@ -552,6 +701,35 @@ export class SignageTemplateService {
}
}
+ /**
+ * Keep a draft over its live record in the list. An approved version
+ * releases it. An unapproved record without a draft, such as a template
+ * that was never approved, is held only when it replaces a held draft.
+ * @param template A template under its live ID
+ */
+ private _holdDraft(template: SignageTemplate) {
+ if (template.approved) {
+ this._releaseDraft(template.id);
+ return;
+ }
+ if (!template.live_template_id && !this._held_drafts()[template.id]) {
+ return;
+ }
+ this._held_drafts.update((held) => ({
+ ...held,
+ [template.id]: template,
+ }));
+ }
+
+ private _releaseDraft(template_id: string) {
+ if (!this._held_drafts()[template_id]) return;
+ this._held_drafts.update((held) => {
+ const next = { ...held };
+ delete next[template_id];
+ return next;
+ });
+ }
+
/** Warn and return true when `template` has unsaved layout edits */
private _templateLayoutUnsaved(template: SignageTemplate) {
const selected_template = this.selected_template();
diff --git a/apps/signage-manager/src/app/templates/template-header.component.ts b/apps/signage-manager/src/app/templates/template-header.component.ts
index e382c724dc3..b8578b9a9f2 100644
--- a/apps/signage-manager/src/app/templates/template-header.component.ts
+++ b/apps/signage-manager/src/app/templates/template-header.component.ts
@@ -1,4 +1,4 @@
-import { Component, computed, inject } from '@angular/core';
+import { Component, inject } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IconComponent, TranslatePipe } from '@placeos/components';
@@ -57,9 +57,7 @@ export class TemplateHeaderComponent {
private readonly _context = inject(SignageContextService);
private readonly _template_service = inject(SignageTemplateService);
- public readonly total_count = computed(
- () => this._template_service.templates().length,
- );
+ public readonly total_count = this._template_service.templates_total;
public readonly can_create = this._context.can_create_templates;
public addTemplate() {
diff --git a/apps/signage-manager/src/app/templates/template-layout.util.ts b/apps/signage-manager/src/app/templates/template-layout.util.ts
index 119d46d2065..68be9b07fc9 100644
--- a/apps/signage-manager/src/app/templates/template-layout.util.ts
+++ b/apps/signage-manager/src/app/templates/template-layout.util.ts
@@ -15,9 +15,14 @@ export interface TemplateLayoutRect {
* The API stores `x_pos`/`y_pos` as ratios from 0 to 1. For edge panels they
* are the panel's size on the relevant axis. For floating panels they are the
* top-left corner, with the panel filling the frame from there.
+ *
+ * These defaults must stay in step with the player
+ * (`apps/signage/src/app/template-layout.ts`), so the preview shows what
+ * screens show and saving does not move a panel.
*/
export const EDGE_BAR_HEIGHT_PC = 15;
export const SIDEBAR_WIDTH_PC = 20;
+// A floating panel with no position fills the frame
export const FLOATING_DEFAULT_X_PC = 0;
export const FLOATING_DEFAULT_Y_PC = 0;
diff --git a/apps/signage-manager/src/app/templates/template-list.component.ts b/apps/signage-manager/src/app/templates/template-list.component.ts
index 6e2ce8aa1fd..f4667a7faf6 100644
--- a/apps/signage-manager/src/app/templates/template-list.component.ts
+++ b/apps/signage-manager/src/app/templates/template-list.component.ts
@@ -11,7 +11,11 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RouterLink } from '@angular/router';
-import { IconComponent, TranslatePipe } from '@placeos/components';
+import {
+ IconComponent,
+ LoadErrorComponent,
+ TranslatePipe,
+} from '@placeos/components';
import { SignageTemplate } from '@placeos/ts-client';
import { IntersectDirective } from '../shared/intersect.directive';
import { SignageTemplateService } from './signage-template.service';
@@ -155,6 +159,8 @@ type TemplateStatus = 'awaiting_approval' | 'awaiting_review' | null;
intersect
(intersect)="loadMore()"
>
+ } @else if (error()) {
+
} @else {
{{ 'COMMON.LOADING' | translate }}
+ } @else if (error()) {
+
} @else {
item.id === plugin_id)
- ?.name || plugin_id
- );
+ return pluginName(this._plugin_service.widgets(), plugin_id);
}
public positionLabel = layoutPositionLabel;
diff --git a/apps/signage-manager/src/app/templates/templates.component.ts b/apps/signage-manager/src/app/templates/templates.component.ts
index d8edf4a2c38..80cdc3b28d2 100644
--- a/apps/signage-manager/src/app/templates/templates.component.ts
+++ b/apps/signage-manager/src/app/templates/templates.component.ts
@@ -1,4 +1,11 @@
-import { Component, effect, inject, input, signal } from '@angular/core';
+import {
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ signal,
+} from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -246,9 +253,9 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details';
('preview');
+ /**
+ * Mobile tab of the layout list panel. It is never 'preview', so the
+ * panel does not share its ID with the preview panel.
+ */
+ public readonly layout_tab = computed(() =>
+ this.view_tab() === 'details' ? 'details' : 'layouts',
+ );
public readonly selected_template =
this._template_service.selected_template;
public readonly requires_approval =
@@ -356,13 +370,21 @@ export class TemplatesSectionComponent {
private readonly _templates = this._template_service.templates;
private _route_resolved = false;
+ // Route id fetched on its own because the loaded pages do not include it
+ private _fetched_id = '';
constructor() {
// Sync selected template from route param
effect(() => {
const id = this.id();
const list = this._templates();
- if (!list.length) return;
+ // A list retry also tries a link whose fetch failed, even when
+ // the list itself does not change
+ this._template_service.templates_retries();
+ // Wait for the list, but a link still opens when it has no rows
+ if (!list.length && !this._template_service.templates_ready()) {
+ return;
+ }
if (id) {
const match = list.find(
(template) =>
@@ -391,28 +413,24 @@ export class TemplatesSectionComponent {
);
}
}
- if (match?.id && match.id !== id) {
- void this._router.navigate(['/templates', match.id], {
- queryParamsHandling: 'merge',
- replaceUrl: true,
- });
- } else if (!match) {
- const selected_template =
- this._template_service.selected_template();
- if (
- selected_template?.id &&
- list.some(
- (template) => template.id === selected_template.id,
- )
- ) {
- void this._router.navigate(
- ['/templates', selected_template.id],
- {
- queryParamsHandling: 'merge',
- replaceUrl: true,
- },
- );
- }
+ // Route by the live ID. A draft ID fails template calls.
+ const match_id = match?.live_template_id || match?.id;
+ if (match_id && match_id !== id) {
+ this._replaceRoute(match_id);
+ } else if (
+ !match &&
+ this._fetched_id !== id &&
+ !(
+ selected &&
+ (selected.id === id || selected.live_template_id === id)
+ )
+ ) {
+ // The loaded pages do not hold it, e.g. a link to a
+ // template past the first page. Fetch it once. A search
+ // can also hide the selected template, which needs no
+ // fetch.
+ this._fetched_id = id;
+ void this._selectFetchedTemplate(id);
}
this._route_resolved = true;
} else if (this._route_resolved) {
@@ -427,9 +445,15 @@ export class TemplatesSectionComponent {
if (template) this._template_service.editTemplate(template);
}
- public removeTemplate() {
+ public async removeTemplate() {
const template = this.selected_template();
- if (template) this._template_service.removeTemplate(template);
+ if (!template) return;
+ if (await this._template_service.removeTemplate(template)) {
+ // Leave the deleted template's route, or it would be selected again
+ void this._router.navigate(['/templates'], {
+ queryParamsHandling: 'merge',
+ });
+ }
}
public async duplicateTemplate() {
@@ -495,4 +519,31 @@ export class TemplatesSectionComponent {
?.querySelectorAll('[role="tab"]')
[next]?.focus();
}
+
+ /**
+ * Fetch the template of the route and select it. The service lists it
+ * unless a search is active, so it is selected here directly.
+ */
+ private async _selectFetchedTemplate(id: string) {
+ const template = await this._template_service.loadTemplate(id);
+ if (!template) {
+ // Let a later list change try the link again
+ if (this._fetched_id === id) this._fetched_id = '';
+ return;
+ }
+ if (this.id() !== id) return;
+ const selected = this._template_service.selected_template();
+ if (!selected || !isSameSignageTemplate(selected, template)) {
+ this._template_service.selected_template.set(template);
+ this._template_service.selected_template_layout_index.set(null);
+ }
+ if (template.id !== id) this._replaceRoute(template.id);
+ }
+
+ private _replaceRoute(id: string) {
+ void this._router.navigate(['/templates', id], {
+ queryParamsHandling: 'merge',
+ replaceUrl: true,
+ });
+ }
}
diff --git a/apps/signage-manager/src/tests/shared/template-edit-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/template-edit-modal.component.spec.ts
index c41e67476a1..5a0dff03b53 100644
--- a/apps/signage-manager/src/tests/shared/template-edit-modal.component.spec.ts
+++ b/apps/signage-manager/src/tests/shared/template-edit-modal.component.spec.ts
@@ -100,6 +100,17 @@ describe('TemplateEditModalComponent', () => {
},
);
+ it('keeps the modal open without a rejected promise when saving fails', async () => {
+ onEdit.mockRejectedValue(new Error('Denied'));
+ const component = await make('template-1');
+
+ await expect(component.saveTemplate()).resolves.toBeUndefined();
+
+ expect(dialog_ref.close).not.toHaveBeenCalled();
+ expect(component.loading()).toBe(false);
+ expect(dialog_ref.disableClose).toBe(false);
+ });
+
it('sends a cleared description and background as null on edit', async () => {
const component = await make('template-1');
component.model.update((model) => ({
diff --git a/apps/signage-manager/src/tests/shared/template-mapping-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/template-mapping-modal.component.spec.ts
index 2e9fa351b75..4322ffbb002 100644
--- a/apps/signage-manager/src/tests/shared/template-mapping-modal.component.spec.ts
+++ b/apps/signage-manager/src/tests/shared/template-mapping-modal.component.spec.ts
@@ -74,6 +74,20 @@ describe('TemplateMappingModalComponent', () => {
expect(save).toHaveBeenCalledWith('template-1', null);
});
+ it('keeps the modal open without a rejected promise when saving fails', async () => {
+ save.mockRejectedValue(new Error('Denied'));
+ const component = await createComponent();
+ component.model.update((value) => ({
+ ...value,
+ template_id: 'template-1',
+ }));
+
+ await expect(component.saveMapping()).resolves.toBeUndefined();
+
+ expect(dialog_ref.close).not.toHaveBeenCalled();
+ expect(component.loading()).toBe(false);
+ });
+
it('saves an unscheduled mapping as the default template', async () => {
const component = await createComponent();
component.model.update((value) => ({
diff --git a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts
index 51141915810..5ee7fdfe560 100644
--- a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts
+++ b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts
@@ -42,6 +42,7 @@ describe('TemplateMappingsComponent', () => {
]),
};
const template_stub = {
+ template_mapping_opening: signal(false),
listTemplateMappings: list_mappings,
editTemplateMapping: edit_mapping,
removeTemplateMapping: remove_mapping,
@@ -83,6 +84,18 @@ describe('TemplateMappingsComponent', () => {
list_mappings.mockResolvedValue([mapping]);
edit_mapping.mockResolvedValue(true);
remove_mapping.mockResolvedValue(true);
+ template_stub.template_mapping_opening.set(false);
+ });
+
+ it('disables apply template while the template picker loads', async () => {
+ template_stub.template_mapping_opening.set(true);
+ const { fixture } = await setup('display', 'display-1', true);
+ fixture.detectChanges();
+
+ const add: HTMLButtonElement =
+ fixture.nativeElement.querySelector('button');
+ expect(add.disabled).toBe(true);
+ expect(add.querySelector('mat-spinner')).toBeTruthy();
});
it('loads mappings for a display and builds preview layout items', async () => {
diff --git a/apps/signage-manager/src/tests/shared/template-request-approval-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/template-request-approval-modal.component.spec.ts
index cf760508446..177a1f7f25d 100644
--- a/apps/signage-manager/src/tests/shared/template-request-approval-modal.component.spec.ts
+++ b/apps/signage-manager/src/tests/shared/template-request-approval-modal.component.spec.ts
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { setNotifyOutlet } from '@placeos/common';
-import { SignageTemplate } from '@placeos/ts-client';
+import { showSignageTemplate, SignageTemplate } from '@placeos/ts-client';
import { TemplateRequestApprovalModalComponent } from '../../app/shared/template-request-approval-modal.component';
import { SignageContextService } from '../../app/signage-context.service';
import { SignagePluginService } from '../../app/signage-plugin.service';
@@ -70,6 +70,25 @@ describe('TemplateRequestApprovalModalComponent', () => {
});
});
+ it('shows an error when the preview versions fail to load', async () => {
+ vi.mocked(showSignageTemplate).mockRejectedValue(new Error('Offline'));
+ const fixture = TestBed.createComponent(
+ TemplateRequestApprovalModalComponent,
+ );
+ const component = fixture.componentInstance;
+
+ component.togglePreview();
+ await vi.waitFor(() => expect(component.versions_error()).toBe(true));
+ fixture.detectChanges();
+
+ expect(component.loading()).toBe('');
+ expect(fixture.nativeElement.textContent).toContain(
+ 'Unable to load template versions.',
+ );
+ await expect(component.undoChanges()).resolves.toBeUndefined();
+ expect(template_service.undoTemplateChanges).not.toHaveBeenCalled();
+ });
+
it('undoes the pending draft from the approval preview', async () => {
const pending = new SignageTemplate({ id: 'template-1' });
const approved = new SignageTemplate({
diff --git a/apps/signage-manager/src/tests/signage-plugin.service.spec.ts b/apps/signage-manager/src/tests/signage-plugin.service.spec.ts
index b36c9efc981..b536ee26164 100644
--- a/apps/signage-manager/src/tests/signage-plugin.service.spec.ts
+++ b/apps/signage-manager/src/tests/signage-plugin.service.spec.ts
@@ -106,4 +106,33 @@ describe('SignagePluginService', () => {
expect(service.plugins().map(({ id }) => id)).toEqual(['plugin-2']);
expect(service.all_plugins()).toHaveLength(2);
});
+
+ it('resolves a loaded plugin without querying again', async () => {
+ vi.mocked(querySignagePlugins).mockResolvedValue({
+ data: [
+ new SignagePlugin({
+ id: 'plugin-1',
+ name: 'Clock',
+ plugin_type: 'plugin',
+ enabled: true,
+ }),
+ ],
+ } as Awaited>);
+ const service = createService();
+ Object.defineProperty(
+ TestBed.inject(SignageContextService),
+ 'can_manage_all_groups',
+ { value: () => true },
+ );
+ TestBed.flushEffects();
+ await vi.waitFor(() => expect(service.all_plugins()).toHaveLength(1));
+ const calls = vi.mocked(querySignagePlugins).mock.calls.length;
+
+ const plugin = await service.resolvePlugin('plugin-1');
+
+ expect(plugin?.name).toBe('Clock');
+ expect(querySignagePlugins).toHaveBeenCalledTimes(calls);
+ expect(await service.resolvePlugin('plugin-2')).toBeUndefined();
+ expect(querySignagePlugins).toHaveBeenCalledTimes(calls + 1);
+ });
});
diff --git a/apps/signage-manager/src/tests/templates/signage-template.service.spec.ts b/apps/signage-manager/src/tests/templates/signage-template.service.spec.ts
index da2c9eebd9a..19512f9fee4 100644
--- a/apps/signage-manager/src/tests/templates/signage-template.service.spec.ts
+++ b/apps/signage-manager/src/tests/templates/signage-template.service.spec.ts
@@ -6,16 +6,20 @@ import {
setNotifyOutlet,
SettingsService,
} from '@placeos/common';
+import { ConfirmModalComponent } from '@placeos/components';
import {
addSignageTemplate,
addSignageTemplateMapping,
listSignageTemplateApprovers,
PlaceCurrentGroup,
query,
+ querySignageTemplates,
removeSignageTemplate,
removeSignageTemplateDraft,
+ removeSignageTemplateMapping,
requestApprovalSignageTemplate,
shareSignageTemplates,
+ showSignageTemplate,
SignageTemplate,
updateSignageTemplate,
updateSignageTemplateMapping,
@@ -49,10 +53,12 @@ describe('SignageTemplateService', () => {
};
const dialog = {
open: vi.fn(),
+ openDialogs: [] as unknown[],
};
beforeEach(() => {
vi.clearAllMocks();
+ dialog.openDialogs = [];
setNotifyOutlet({ open: notify_open } as any, true);
settings.get.mockReturnValue(false);
dialog.open.mockReturnValue({
@@ -146,6 +152,20 @@ describe('SignageTemplateService', () => {
});
});
+ it('drops a deleted template from the loaded list at once', async () => {
+ confirmNextDialog();
+ const service = createService();
+ vi.mocked(removeSignageTemplate).mockResolvedValue({});
+ const deleted = new SignageTemplate({ id: 'template-1' });
+ const kept = new SignageTemplate({ id: 'template-2' });
+ (service as any)._template_list.update(() => [deleted, kept]);
+
+ const removed = await service.removeTemplate(deleted);
+
+ expect(removed).toBe(true);
+ expect(service.templates().map(({ id }) => id)).toEqual(['template-2']);
+ });
+
it('shows an error and closes the confirm modal when delete fails', async () => {
confirmNextDialog();
const service = createService();
@@ -355,6 +375,7 @@ describe('SignageTemplateService', () => {
});
service.selected_template.set(draft);
vi.mocked(removeSignageTemplateDraft).mockResolvedValue(undefined);
+ confirmNextDialog();
const undone = await service.undoTemplateChanges(draft.id, approved);
@@ -365,6 +386,53 @@ describe('SignageTemplateService', () => {
expect(service.selected_template()).toBe(approved);
});
+ it('keeps the undo confirmation open until the draft is removed', async () => {
+ const service = createService();
+ const confirm_ref = {
+ componentInstance: Object.assign(
+ Object.create(ConfirmModalComponent.prototype),
+ { event: of({ reason: 'done' }), loading: { set: vi.fn() } },
+ ),
+ afterClosed: () => NEVER,
+ close: vi.fn(),
+ disableClose: false,
+ };
+ dialog.open.mockReturnValue(confirm_ref);
+ dialog.openDialogs = [confirm_ref];
+ let locked_during_request = false;
+ vi.mocked(removeSignageTemplateDraft).mockImplementation(async () => {
+ locked_during_request = confirm_ref.disableClose;
+ });
+
+ await service.undoTemplateChanges(
+ 'template-1',
+ new SignageTemplate({ id: 'template-1', approved: true }),
+ );
+
+ expect(locked_during_request).toBe(true);
+ expect(confirm_ref.close).toHaveBeenCalled();
+ });
+
+ it('keeps the pending draft when the user cancels undo', async () => {
+ const service = createService();
+ const draft = new SignageTemplate({ id: 'template-1' });
+ service.selected_template.set(draft);
+ dialog.open.mockReturnValue({
+ componentInstance: { event: NEVER, loading: { set: vi.fn() } },
+ afterClosed: () => of(undefined),
+ close: vi.fn(),
+ });
+
+ const undone = await service.undoTemplateChanges(
+ draft.id,
+ new SignageTemplate({ id: 'template-1', approved: true }),
+ );
+
+ expect(undone).toBe(false);
+ expect(removeSignageTemplateDraft).not.toHaveBeenCalled();
+ expect(service.selected_template()).toBe(draft);
+ });
+
it('updates template approval state in the list and selection', () => {
const service = createService();
const test_service =
@@ -383,7 +451,7 @@ describe('SignageTemplateService', () => {
expect(service.selected_template_requires_approval()).toBe(false);
});
- it('replaces an approved template with its new draft ID', () => {
+ it('keeps an approved template under its live ID when it gets a draft', () => {
const service = createService();
const test_service =
service as unknown as SignageTemplateServiceTestAccess;
@@ -400,8 +468,13 @@ describe('SignageTemplateService', () => {
service.updateCachedTemplate(draft);
- expect(service.templates()).toEqual([draft]);
- expect(service.selected_template()).toBe(draft);
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'template-live',
+ ]);
+ expect(service.selected_template()?.id).toBe('template-live');
+ expect(service.selected_template()?.live_template_id).toBe(
+ 'template-live',
+ );
});
it('replaces a draft with its approved template', () => {
@@ -455,6 +528,36 @@ describe('SignageTemplateService', () => {
});
});
+ it('keeps the live ID when a layout save returns a draft', async () => {
+ const service = createService();
+ const test_service =
+ service as unknown as SignageTemplateServiceTestAccess;
+ const approved = new SignageTemplate({
+ id: 'template-live',
+ approved: true,
+ layouts: [],
+ });
+ test_service['_template_list'].update(() => [approved]);
+ service.selected_template.set(approved);
+ service.template_layout_draft.set([
+ { position: 'top', plugin_params: {} },
+ ]);
+ vi.mocked(updateSignageTemplate).mockResolvedValue(
+ new SignageTemplate({
+ id: 'template-draft',
+ live_template_id: 'template-live',
+ }),
+ );
+
+ await service.saveTemplateLayouts();
+
+ expect(service.selected_template()?.id).toBe('template-live');
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'template-live',
+ ]);
+ expect(service.template_layout_dirty()).toBe(false);
+ });
+
it('keeps saved plugin details when the update response omits them', async () => {
const service = createService();
service.selected_template.set(
@@ -509,7 +612,7 @@ describe('SignageTemplateService', () => {
data: [
options.fn({
id: 'template-1',
- name: 'Welcome',
+ name: 'Sales & Marketing',
approved: true,
}),
],
@@ -518,6 +621,7 @@ describe('SignageTemplateService', () => {
const templates = await service.listApprovedTemplates();
expect(templates[0]).toBeInstanceOf(SignageTemplate);
+ expect(templates[0].name).toBe('Sales & Marketing');
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
path: 'signage/templates',
@@ -543,7 +647,7 @@ describe('SignageTemplateService', () => {
id: 'mapping-1',
zone_id: 'zone-1',
template_id: 'template-1',
- template_details: { name: 'Welcome' },
+ template_details: { name: 'Sales & Marketing' },
}),
],
}));
@@ -553,7 +657,8 @@ describe('SignageTemplateService', () => {
});
expect(mappings[0]).toBeInstanceOf(HydratedSignageTemplateMapping);
- expect(mappings[0].template_details.name).toBe('Welcome');
+ expect(mappings[0].template_details.name).toBe('Sales & Marketing');
+ expect(mappings[0].template_details.id).toBe('template-1');
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
path: 'signage/template_mappings',
@@ -562,6 +667,43 @@ describe('SignageTemplateService', () => {
);
});
+ it('shows an error instead of the mapping modal when templates fail to load', async () => {
+ const service = createService();
+ vi.spyOn(service, 'listApprovedTemplates').mockRejectedValue(
+ new Error('Offline'),
+ );
+
+ const changed = await service.editTemplateMapping({
+ control_system_id: 'display-1',
+ });
+
+ expect(changed).toBe(false);
+ expect(dialog.open).not.toHaveBeenCalled();
+ expect(service.template_mapping_opening()).toBe(false);
+ expect(notify_open).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.anything(),
+ expect.objectContaining({ panelClass: ['error'] }),
+ );
+ });
+
+ it('returns false when removing a mapping fails', async () => {
+ const service = createService();
+ vi.mocked(removeSignageTemplateMapping).mockRejectedValue(
+ new Error('Denied'),
+ );
+ confirmNextDialog();
+
+ const removed = await service.removeTemplateMapping(
+ new HydratedSignageTemplateMapping({
+ id: 'mapping-1',
+ template_details: { name: 'Welcome' },
+ }),
+ );
+
+ expect(removed).toBe(false);
+ });
+
it('creates a mapping with its target and updates only its schedule', async () => {
const service = createService();
vi.spyOn(service, 'listApprovedTemplates').mockResolvedValue([
@@ -611,4 +753,261 @@ describe('SignageTemplateService', () => {
schedule: null,
});
});
+
+ describe('template list', () => {
+ type TemplatePage = Awaited>;
+
+ function page(ids: string[], total = ids.length): TemplatePage {
+ return {
+ data: ids.map((id) => new SignageTemplate({ id, name: id })),
+ total,
+ next: () => null,
+ } as unknown as TemplatePage;
+ }
+
+ async function loadedService() {
+ const service = createService();
+ const context = TestBed.inject(SignageContextService);
+ Object.defineProperty(context, 'can_manage_all_groups', {
+ value: () => true,
+ });
+ await vi.waitFor(() =>
+ expect(context.templates_enabled()).toBe(true),
+ );
+ TestBed.flushEffects();
+ return { service, context };
+ }
+
+ it('keeps the loaded templates on screen while a data change reloads them', async () => {
+ vi.mocked(querySignageTemplates).mockResolvedValue(
+ page(['template-1'], 450),
+ );
+ const { service, context } = await loadedService();
+ await vi.waitFor(() => expect(service.templates()).toHaveLength(1));
+ expect(service.templates_total()).toBe(450);
+
+ vi.mocked(querySignageTemplates).mockReturnValue(
+ new Promise(() => {}),
+ );
+ context.changed();
+ TestBed.flushEffects();
+
+ expect(querySignageTemplates).toHaveBeenCalledTimes(2);
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'template-1',
+ ]);
+ });
+
+ it('counts the user retries of the list', () => {
+ const service = createService();
+
+ service.reloadTemplates();
+
+ expect(service.templates_retries()).toBe(1);
+ });
+
+ it('shows a load error and reloads the list on retry', async () => {
+ vi.mocked(querySignageTemplates).mockRejectedValue(
+ new Error('Offline'),
+ );
+ const { service } = await loadedService();
+ await vi.waitFor(() =>
+ expect(service.templates_error()).toBe(true),
+ );
+
+ vi.mocked(querySignageTemplates).mockResolvedValue(
+ page(['template-1']),
+ );
+ service.reloadTemplates();
+ TestBed.flushEffects();
+
+ await vi.waitFor(() => expect(service.templates()).toHaveLength(1));
+ expect(service.templates_error()).toBe(false);
+ });
+
+ function livePage(): TemplatePage {
+ return {
+ data: [
+ new SignageTemplate({
+ id: 'template-live',
+ name: 'A',
+ approved: true,
+ }),
+ new SignageTemplate({ id: 'other', name: 'B' }),
+ ],
+ total: 2,
+ next: () => null,
+ } as unknown as TemplatePage;
+ }
+
+ const draft = () =>
+ new SignageTemplate({
+ id: 'template-draft',
+ live_template_id: 'template-live',
+ name: 'A',
+ });
+
+ /** Reload the list after a data change, with the live record in the page */
+ async function reloadWithLiveRecord(
+ service: SignageTemplateService,
+ context: SignageContextService,
+ ) {
+ const calls = vi.mocked(querySignageTemplates).mock.calls.length;
+ vi.mocked(querySignageTemplates).mockResolvedValue(livePage());
+ context.changed();
+ TestBed.flushEffects();
+ expect(querySignageTemplates).toHaveBeenCalledTimes(calls + 1);
+ await vi.waitFor(() => {
+ expect(service.templates_loading()).toBe(false);
+ expect(service.templates()).toHaveLength(2);
+ });
+ }
+
+ it('keeps a fetched draft when a page returns its live record', async () => {
+ vi.mocked(querySignageTemplates).mockResolvedValue(page(['other']));
+ const { service, context } = await loadedService();
+ await vi.waitFor(() => expect(service.templates()).toHaveLength(1));
+ vi.mocked(showSignageTemplate).mockResolvedValue(draft());
+ await service.loadTemplate('template-live');
+
+ await reloadWithLiveRecord(service, context);
+
+ const row = service
+ .templates()
+ .find(({ id }) => id === 'template-live');
+ expect(row?.approved).toBe(false);
+ expect(row?.live_template_id).toBe('template-live');
+ });
+
+ it('keeps a saved layout draft when the list reloads', async () => {
+ vi.mocked(querySignageTemplates).mockResolvedValue(livePage());
+ const { service, context } = await loadedService();
+ await vi.waitFor(() => expect(service.templates()).toHaveLength(2));
+ service.selected_template.set(service.templates()[0]);
+ service.template_layout_draft.set([
+ { position: 'top', plugin_params: {} },
+ ]);
+ vi.mocked(updateSignageTemplate).mockResolvedValue(draft());
+ await service.saveTemplateLayouts();
+
+ await reloadWithLiveRecord(service, context);
+
+ expect(service.templates()[0].approved).toBe(false);
+ expect(service.templates()[0].layouts).toEqual([
+ { position: 'top', plugin_params: {}, y_pos: 0.15 },
+ ]);
+ });
+
+ it('shows the live record again once the draft is approved', async () => {
+ vi.mocked(querySignageTemplates).mockResolvedValue(livePage());
+ const { service, context } = await loadedService();
+ await vi.waitFor(() => expect(service.templates()).toHaveLength(2));
+ service.updateCachedTemplate(draft());
+ expect(service.templates()[0].approved).toBe(false);
+
+ service.updateCachedTemplate(
+ new SignageTemplate({
+ id: 'template-live',
+ name: 'A (approved)',
+ approved: true,
+ }),
+ );
+ await reloadWithLiveRecord(service, context);
+
+ // The reloaded record wins, as nothing is held any more
+ expect(service.templates()[0].name).toBe('A');
+ expect(service.templates()[0].approved).toBe(true);
+ });
+
+ it('drops a fetched template when the group changes while it loads', async () => {
+ const service = createService();
+ let group_id = 'group-1';
+ Object.defineProperty(
+ TestBed.inject(SignageContextService),
+ 'api_group_id',
+ { value: () => group_id },
+ );
+ let resolve: (template: SignageTemplate) => void = () => {};
+ vi.mocked(showSignageTemplate).mockReturnValue(
+ new Promise((done) => (resolve = done)),
+ );
+
+ const loading = service.loadTemplate('template-old');
+ group_id = 'group-2';
+ resolve(new SignageTemplate({ id: 'template-old', name: 'Old' }));
+
+ expect(await loading).toBeNull();
+ expect(service.templates()).toEqual([]);
+ });
+
+ it('stores a fetched draft under its live ID as one row', async () => {
+ const service = createService();
+ const test_service =
+ service as unknown as SignageTemplateServiceTestAccess;
+ test_service['_template_list'].update(() => [
+ new SignageTemplate({ id: 'template-live', name: 'A' }),
+ ]);
+ vi.mocked(showSignageTemplate).mockResolvedValue(
+ new SignageTemplate({
+ id: 'template-draft',
+ live_template_id: 'template-live',
+ name: 'A',
+ }),
+ );
+
+ const template = await service.loadTemplate('template-live');
+
+ expect(template?.id).toBe('template-live');
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'template-live',
+ ]);
+ });
+
+ it('keeps a fetched template out of search results', async () => {
+ vi.mocked(querySignageTemplates).mockResolvedValue(
+ page(['bulk-19']),
+ );
+ const { service } = await loadedService();
+ service.template_search_term.set('bulk-19');
+ await vi.waitFor(() => {
+ TestBed.flushEffects();
+ expect(querySignageTemplates).toHaveBeenLastCalledWith(
+ expect.objectContaining({ q: 'bulk-19' }),
+ );
+ });
+ await vi.waitFor(() =>
+ expect(service.templates_loading()).toBe(false),
+ );
+ vi.mocked(showSignageTemplate).mockResolvedValue(
+ new SignageTemplate({ id: 'template-req', name: 'req' }),
+ );
+
+ const template = await service.loadTemplate('template-req');
+
+ expect(template?.id).toBe('template-req');
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'bulk-19',
+ ]);
+ });
+
+ it('adds a template fetched by ID to the loaded templates', async () => {
+ const service = createService();
+ const test_service =
+ service as unknown as SignageTemplateServiceTestAccess;
+ test_service['_template_list'].update(() => [
+ new SignageTemplate({ id: 'template-1', name: 'B' }),
+ ]);
+ vi.mocked(showSignageTemplate).mockResolvedValue(
+ new SignageTemplate({ id: 'template-300', name: 'A & Z' }),
+ );
+
+ const template = await service.loadTemplate('template-300');
+
+ expect(template?.name).toBe('A & Z');
+ expect(service.templates().map(({ id }) => id)).toEqual([
+ 'template-300',
+ 'template-1',
+ ]);
+ });
+ });
});
diff --git a/apps/signage-manager/src/tests/templates/template-header.component.spec.ts b/apps/signage-manager/src/tests/templates/template-header.component.spec.ts
new file mode 100644
index 00000000000..8749bed9e4f
--- /dev/null
+++ b/apps/signage-manager/src/tests/templates/template-header.component.spec.ts
@@ -0,0 +1,36 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { SignageContextService } from '../../app/signage-context.service';
+import { SignageTemplateService } from '../../app/templates/signage-template.service';
+import { TemplateHeaderComponent } from '../../app/templates/template-header.component';
+
+describe('TemplateHeaderComponent', () => {
+ it('counts every template on the server, not only the loaded pages', async () => {
+ await TestBed.configureTestingModule({
+ imports: [TemplateHeaderComponent],
+ providers: [
+ {
+ provide: SignageContextService,
+ useValue: { can_create_templates: signal(false) },
+ },
+ {
+ provide: SignageTemplateService,
+ useValue: {
+ templates: signal([{ id: 'template-1' }]),
+ templates_total: signal(450),
+ },
+ },
+ ],
+ })
+ .overrideComponent(TemplateHeaderComponent, {
+ set: { template: '', imports: [] },
+ })
+ .compileComponents();
+
+ const component = TestBed.createComponent(
+ TemplateHeaderComponent,
+ ).componentInstance;
+
+ expect(component.total_count()).toBe(450);
+ });
+});
diff --git a/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts b/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts
index bf307147422..651b04d2c55 100644
--- a/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts
+++ b/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts
@@ -124,6 +124,16 @@ describe('computeTemplateLayoutRects', () => {
expect(footer.width).toBe(100);
});
+ it('fills the frame with a floating panel that has no position, as the player does', () => {
+ // apps/signage/src/app/template-layout.ts FLOATING_POSITION
+ const [rect] = computeTemplateLayoutRects([layout('floating')]);
+ expect(rect).toEqual({ left: 0, top: 0, width: 100, height: 100 });
+ expect(applyLayoutPositionDefaults(layout('floating'))).toMatchObject({
+ x_pos: 0,
+ y_pos: 0,
+ });
+ });
+
it('uses the default corner for floating panels without a position', () => {
const [rect] = computeTemplateLayoutRects([layout('floating')]);
expect(rect).toEqual({
diff --git a/apps/signage-manager/src/tests/templates/template-list.component.spec.ts b/apps/signage-manager/src/tests/templates/template-list.component.spec.ts
index 0b30bf4c138..98226caf770 100644
--- a/apps/signage-manager/src/tests/templates/template-list.component.spec.ts
+++ b/apps/signage-manager/src/tests/templates/template-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 { SignageTemplate } from '@placeos/ts-client';
import { SignageTemplateService } from '../../app/templates/signage-template.service';
import { TemplateListComponent } from '../../app/templates/template-list.component';
@@ -11,7 +12,10 @@ describe('TemplateListComponent', () => {
templates: signal([]),
selected_template: signal(null),
templates_has_more: signal(false),
+ templates_loading: signal(false),
+ templates_error: signal(false),
loadMoreTemplates: load_more,
+ reloadTemplates: vi.fn(),
};
async function make() {
@@ -28,7 +32,29 @@ describe('TemplateListComponent', () => {
return TestBed.createComponent(TemplateListComponent).componentInstance;
}
- beforeEach(() => vi.clearAllMocks());
+ beforeEach(() => {
+ vi.clearAllMocks();
+ template_stub.templates.set([]);
+ template_stub.templates_error.set(false);
+ });
+
+ it('shows a load error with a retry instead of the empty list', async () => {
+ template_stub.templates_error.set(true);
+ await TestBed.configureTestingModule({
+ imports: [TemplateListComponent],
+ providers: [
+ provideRouter([]),
+ { provide: SignageTemplateService, useValue: template_stub },
+ ],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(TemplateListComponent);
+ fixture.detectChanges();
+ const element: HTMLElement = fixture.nativeElement;
+
+ expect(element.querySelector('load-error')).not.toBeNull();
+ element.querySelector('load-error button')?.click();
+ expect(template_stub.reloadTemplates).toHaveBeenCalledOnce();
+ });
it('marks an unrequested draft as awaiting approval', async () => {
const component = await make();
diff --git a/apps/signage-manager/src/tests/templates/templates.component.spec.ts b/apps/signage-manager/src/tests/templates/templates.component.spec.ts
index b531a6fb99e..ab48b105d73 100644
--- a/apps/signage-manager/src/tests/templates/templates.component.spec.ts
+++ b/apps/signage-manager/src/tests/templates/templates.component.spec.ts
@@ -22,6 +22,9 @@ describe('TemplatesSectionComponent', () => {
shareTemplate: vi.fn(),
approveTemplate: vi.fn(),
requestTemplateApproval: vi.fn(),
+ loadTemplate: vi.fn(),
+ templates_ready: signal(false),
+ templates_retries: signal(0),
};
const context_stub = {
can_approve: signal(false),
@@ -56,6 +59,9 @@ describe('TemplatesSectionComponent', () => {
template_stub.selected_template_layout_index.set(null);
template_stub.template_layout_dirty.set(false);
template_stub.templates.set([]);
+ template_stub.loadTemplate.mockResolvedValue(null);
+ template_stub.templates_ready.set(false);
+ template_stub.templates_retries.set(0);
TestBed.resetTestingModule();
});
@@ -129,7 +135,7 @@ describe('TemplatesSectionComponent', () => {
expect(template_stub.shareTemplate).toHaveBeenCalledWith(template);
});
- it('selects a draft from an approved route and stores the draft ID', async () => {
+ it('keeps the live ID when the list holds a draft record', async () => {
const fixture = await makeFixture();
const draft = {
id: 'template-draft',
@@ -140,20 +146,31 @@ describe('TemplatesSectionComponent', () => {
await fixture.whenStable();
expect(template_stub.selected_template()).toBe(draft);
- expect(navigate).toHaveBeenCalledWith(
- ['/templates', 'template-draft'],
- {
- queryParamsHandling: 'merge',
- replaceUrl: true,
- },
- );
+ expect(navigate).not.toHaveBeenCalled();
});
- it('stores the approved ID after approving a draft', async () => {
+ it('leaves the route of a template once it is deleted', async () => {
+ const component = await make();
+ template_stub.selected_template.set({ id: 'template-1' });
+ template_stub.removeTemplate.mockResolvedValueOnce(false);
+
+ await component.removeTemplate();
+ expect(navigate).not.toHaveBeenCalled();
+
+ template_stub.removeTemplate.mockResolvedValueOnce(true);
+ await component.removeTemplate();
+ expect(navigate).toHaveBeenCalledWith(['/templates'], {
+ queryParamsHandling: 'merge',
+ });
+ });
+
+ it('routes a draft ID link to the live ID', async () => {
const fixture = await makeFixture();
- const approved = { id: 'template-live' };
- template_stub.selected_template.set(approved);
- template_stub.templates.set([approved]);
+ const draft = {
+ id: 'template-draft',
+ live_template_id: 'template-live',
+ };
+ template_stub.templates.set([draft]);
fixture.componentRef.setInput('id', 'template-draft');
await fixture.whenStable();
@@ -163,6 +180,55 @@ describe('TemplatesSectionComponent', () => {
});
});
+ it('fetches a linked template instead of returning to the selected one', async () => {
+ const fixture = await makeFixture();
+ const selected = { id: 'template-1' };
+ const linked = { id: 'template-300' };
+ template_stub.loadTemplate.mockResolvedValue(linked);
+ template_stub.selected_template.set(selected);
+ template_stub.templates.set([selected]);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+
+ await vi.waitFor(() =>
+ expect(template_stub.selected_template()).toBe(linked),
+ );
+ expect(template_stub.loadTemplate).toHaveBeenCalledWith('template-300');
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it('opens a linked template when the loaded list has no rows', async () => {
+ const fixture = await makeFixture();
+ template_stub.templates_ready.set(true);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).toHaveBeenCalledWith('template-300');
+ });
+
+ it('waits for the first page before it fetches a linked template', async () => {
+ const fixture = await makeFixture();
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).not.toHaveBeenCalled();
+ });
+
+ it('tries a failed link again when the list changes', async () => {
+ const fixture = await makeFixture();
+ template_stub.templates.set([{ id: 'template-1' }]);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+ await vi.waitFor(() =>
+ expect(template_stub.loadTemplate).toHaveBeenCalledTimes(1),
+ );
+
+ template_stub.templates.set([{ id: 'template-1' }, { id: 'other' }]);
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).toHaveBeenCalledTimes(2);
+ });
+
it('resets the layout selection when switching templates', async () => {
const fixture = await makeFixture();
const first = { id: 'template-1' };
@@ -194,6 +260,81 @@ describe('TemplatesSectionComponent', () => {
expect(template_stub.selected_template_layout_index()).toBe(1);
});
+ it('selects a fetched template that a search keeps out of the list', async () => {
+ const fixture = await makeFixture();
+ const fetched = { id: 'template-300' };
+ template_stub.loadTemplate.mockResolvedValue(fetched);
+ template_stub.templates.set([{ id: 'match-1' }]);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+
+ await vi.waitFor(() =>
+ expect(template_stub.selected_template()).toBe(fetched),
+ );
+ expect(template_stub.templates()).toEqual([{ id: 'match-1' }]);
+ });
+
+ it('does not fetch the selected template when a search hides it', async () => {
+ const fixture = await makeFixture();
+ const selected = { id: 'template-req' };
+ template_stub.selected_template.set(selected);
+ template_stub.templates.set([{ id: 'bulk-19' }]);
+ fixture.componentRef.setInput('id', 'template-req');
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).not.toHaveBeenCalled();
+ expect(template_stub.selected_template()).toBe(selected);
+ });
+
+ it('tries a failed link again when the user retries the list', async () => {
+ const fixture = await makeFixture();
+ let fail: (value: null) => void = () => {};
+ template_stub.loadTemplate.mockReturnValueOnce(
+ new Promise((done) => (fail = done)),
+ );
+ template_stub.templates.set([{ id: 'template-1' }]);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+ // The list reloads while the link is still loading
+ template_stub.templates.set([{ id: 'template-1' }, { id: 'other' }]);
+ await fixture.whenStable();
+ fail(null);
+ await vi.waitFor(() =>
+ expect(template_stub.loadTemplate).toHaveBeenCalledTimes(1),
+ );
+ await fixture.whenStable();
+
+ template_stub.templates_retries.update((count) => count + 1);
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).toHaveBeenCalledTimes(2);
+ });
+
+ it('fetches a linked template that is not in the loaded pages once', async () => {
+ const fixture = await makeFixture();
+ // Still loading when the list changes
+ template_stub.loadTemplate.mockReturnValue(new Promise(() => {}));
+ template_stub.templates.set([{ id: 'template-1' }]);
+ fixture.componentRef.setInput('id', 'template-300');
+ await fixture.whenStable();
+
+ template_stub.templates.set([{ id: 'template-1' }, { id: 'other' }]);
+ await fixture.whenStable();
+
+ expect(template_stub.loadTemplate).toHaveBeenCalledExactlyOnceWith(
+ 'template-300',
+ );
+ });
+
+ it('gives the layout list panel an ID apart from the preview panel', async () => {
+ const component = await make();
+
+ expect(component.view_tab()).toBe('preview');
+ expect(component.layout_tab()).toBe('layouts');
+ component.setViewTab('details');
+ expect(component.layout_tab()).toBe('details');
+ });
+
it('keeps unsaved layout edits when a list reload returns the same template', async () => {
const fixture = await makeFixture();
const stale = { id: 'template-1' };
diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json
index c0387375ee5..9d9ea9834db 100644
--- a/shared/assets/locale/en-AU.json
+++ b/shared/assets/locale/en-AU.json
@@ -802,6 +802,7 @@
"TEMPLATE_REMOVE_LAYOUT": "Remove layout item",
"TEMPLATE_REQUIRED": "Select a template",
"TEMPLATE_REVERTED": "Template reverted to previous version",
+ "TEMPLATE_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.",
"TEMPLATE_REVERT_ERROR": "Error reverting template changes",
"TEMPLATE_SAVED": "Template saved",
"TEMPLATE_SAVE_ERROR": "Error saving template",