From ef45cc2d6420c929774d5841d19a1e330e93b815 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:01:08 +1000 Subject: [PATCH 01/19] fix(triggers): build and parse valid cron schedules - Weekly and yearly schedules used indexOf on translated labels, which gave -1. Use the selected weekday and month values directly. - Parsing set the day of month from the hour and mapped periods off by one. Move build/parse into tested helpers and remove the dead block. - Fix the hour picker trigger label that printed the signal source. - Cron input: fix the day range regex and allow Tab, Home/End and modifier shortcuts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/triggers/triggers.utilities.ts | 78 +++++++++++ .../cron-input-field.component.ts | 11 +- .../time-form.component.ts | 124 ++++-------------- src/tests/triggers/triggers.utilities.spec.ts | 36 +++++ 4 files changed, 149 insertions(+), 100 deletions(-) diff --git a/src/app/triggers/triggers.utilities.ts b/src/app/triggers/triggers.utilities.ts index a7eb648f3..7954a8243 100644 --- a/src/app/triggers/triggers.utilities.ts +++ b/src/app/triggers/triggers.utilities.ts @@ -203,3 +203,81 @@ export function applyTriggerActionFormSchema( }, }); } + +export type CronPeriod = + | 'minute' + | 'hour' + | 'day' + | 'week' + | 'month' + | 'year' + | 'custom'; + +/** Values for the simple schedule editor. Month is 1-12, weekday is 0-6. */ +export interface CronParts { + minute: number; + hour: number; + day_of_week: number; + day_of_month: number; + month: number; +} + +/** + * Build a cron string from the simple schedule editor values. + * Returns `null` for the `custom` period as the string is edited directly. + */ +export function buildCronString( + period: CronPeriod, + parts: CronParts, +): string | null { + const minute = parts.minute % 60; + const { hour, day_of_week, day_of_month, month } = parts; + switch (period) { + case 'minute': + return minute ? `*/${minute} * * * *` : '* * * * *'; + case 'hour': + return hour ? `${minute} */${hour} * * *` : `${minute} * * * *`; + case 'day': + return `${minute} ${hour} * * *`; + case 'week': + return `${minute} ${hour} * * ${day_of_week}`; + case 'month': + return `${minute} ${hour} ${day_of_month} * *`; + case 'year': + return `${minute} ${hour} ${day_of_month} ${month} *`; + } + return null; +} + +/** + * Parse a cron string into the simple schedule editor values. + * Strings with ranges, steps or lists use the `custom` period. + */ +export function parseCronString(cron: string): { + period: CronPeriod; + parts: CronParts; +} { + const cron_str = (cron || '').trim() || '* * * * *'; + const [minute, hour, day, month, weekday] = cron_str.split(/\s+/); + const parts: CronParts = { + minute: +minute || 0, + hour: +hour || 0, + day_of_week: +weekday || 0, + day_of_month: +day || 1, + month: +month || 1, + }; + if (/[-/,]/.test(cron_str)) return { period: 'custom', parts }; + const is_set = (value?: string) => !!value && value !== '*'; + const period: CronPeriod = is_set(month) + ? 'year' + : is_set(day) + ? 'month' + : is_set(weekday) + ? 'week' + : is_set(hour) + ? 'day' + : is_set(minute) + ? 'hour' + : 'minute'; + return { period, parts }; +} diff --git a/src/app/ui/custom-fields/cron-input-field.component.ts b/src/app/ui/custom-fields/cron-input-field.component.ts index 980095fbb..8b67e107b 100644 --- a/src/app/ui/custom-fields/cron-input-field.component.ts +++ b/src/app/ui/custom-fields/cron-input-field.component.ts @@ -31,6 +31,11 @@ const VALID_INPUT = [ 'ArrowUp', 'ArrowDown', 'Delete', + 'Tab', + 'Home', + 'End', + 'Enter', + 'Escape', ]; function listPattern(fieldPattern) { // One instance of `fieldPattern` plus any number of `,fieldPattern` @@ -120,7 +125,7 @@ export class CronInputFieldComponent implements ControlValueAccessor { this.validatePattern( path.day, listPattern( - '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|[12]\\d|3[01])(?:-(?:[1-9]|[12]d|3[01]))?(?:/\\d{1,2})?)', + '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|[12]\\d|3[01])(?:-(?:[1-9]|[12]\\d|3[01]))?(?:/\\d{1,2})?)', ), ); this.validatePattern( @@ -176,7 +181,9 @@ export class CronInputFieldComponent implements ControlValueAccessor { } public preventInvalidCharacters(event: KeyboardEvent): void { - if (this.disabled()) event.preventDefault(); + if (this.disabled()) return event.preventDefault(); + // Allow shortcuts such as copy, paste and select all + if (event.ctrlKey || event.metaKey || event.altKey) return; if (!VALID_INPUT.includes(event.key)) event.preventDefault(); } diff --git a/src/app/ui/forms/trigger-condition-form/time-form.component.ts b/src/app/ui/forms/trigger-condition-form/time-form.component.ts index c98a56794..d94e0bad1 100644 --- a/src/app/ui/forms/trigger-condition-form/time-form.component.ts +++ b/src/app/ui/forms/trigger-condition-form/time-form.component.ts @@ -20,7 +20,12 @@ import { numberToPosition } from '../../../common/general'; import { i18n } from '../../../common/locale.service'; import { TIMEZONES_IANA } from '../../../common/timezones'; import { Identity } from '../../../common/types'; -import { TriggerConditionFormModel } from '../../../triggers/triggers.utilities'; +import { + buildCronString, + CronPeriod, + parseCronString, + TriggerConditionFormModel, +} from '../../../triggers/triggers.utilities'; import { CronInputFieldComponent } from '../../custom-fields/cron-input-field.component'; import { DateFieldComponent } from '../../custom-fields/date-field.component'; import { TimeFieldComponent } from '../../custom-fields/time-field.component'; @@ -226,8 +231,7 @@ import { TranslatePipe } from '../../translate.pipe'; }" > - cron_hour() - {{ pad(cron_hour) }}:00 @@ -327,9 +331,7 @@ export class TriggerConditionTimeFormComponent /** Whether condition is a cron(recurring) job */ public readonly is_cron = signal(null); /** The period which the user selects the recurrence */ - public readonly cron_period: WritableSignal< - 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' | 'custom' - > = signal('minute'); + public readonly cron_period: WritableSignal = signal('minute'); public minutes_in_hour = new Array(12).fill(0).map((_, idx) => idx * 5); public hours_in_day = new Array(24).fill(0).map((_, idx) => idx); @@ -444,33 +446,16 @@ export class TriggerConditionTimeFormComponent public updateCronString() { const form = this.form(); if (form && form.cron) { - const hour = this.cron_hour(); - const minute = this.cron_minute() % 60; - const day_of_week = this.days_of_week().indexOf(this.cron_day()); - const day_of_month = this.cron_date(); - const month = this.months_of_year().indexOf(this.cron_month()); - let cron_str = '* * * * *'; - switch (this.cron_period()) { - case 'minute': - cron_str = minute ? `*/${minute} * * * *` : '* * * * *'; - break; - case 'hour': - cron_str = hour - ? `${minute} */${hour} * * *` - : `${minute} * * * *`; - break; - case 'day': - cron_str = `${minute} ${hour} * * *`; - break; - case 'week': - cron_str = `${minute} ${hour} * * ${day_of_week}`; - break; - case 'month': - cron_str = `${minute} ${hour} ${day_of_month} * *`; - break; - case 'year': - cron_str = `${minute} ${hour} ${day_of_month} ${month} *`; - break; + const cron_str = buildCronString(this.cron_period(), { + minute: this.cron_minute(), + hour: this.cron_hour(), + day_of_week: this.cron_day(), + day_of_month: this.cron_date(), + month: this.cron_month(), + }); + if (cron_str === null) { + this.cron_string.set(this.formModel()().cron); + return; } this.formModel().update((model) => ({ ...model, cron: cron_str })); } @@ -478,71 +463,14 @@ export class TriggerConditionTimeFormComponent private loadCronTab(cron_tab: string): void { this.cron_string.set(cron_tab); - if ( - this.cron_string().includes('-') || - this.cron_string().includes('/') || - this.cron_string().includes(',') - ) { - this.cron_period.set('custom'); - return; - } - const [minute, hour, day, month, weekday] = cron_tab.split(' '); - this.cron_minute.set(+minute || 0); - this.cron_hour.set(+hour || 0); - this.cron_day.set(+weekday || 0); - this.cron_date.set(+hour || 1); - this.cron_month.set(+month - 1); - this.cron_period.set('minute'); - if (month !== '*') { - this.cron_period.set('month'); - } else if (weekday !== '*') { - this.cron_period.set('week'); - } else if (day !== '*') { - this.cron_period.set('day'); - } else if (hour !== '*') { - this.cron_period.set('hour'); - } + const { period, parts } = parseCronString(cron_tab); + this.cron_period.set(period); + if (period === 'custom') return; + this.cron_minute.set(parts.minute); + this.cron_hour.set(parts.hour); + this.cron_day.set(parts.day_of_week); + this.cron_date.set(parts.day_of_month); + this.cron_month.set(parts.month); this.cron_hour_period = this.cron_hour() > 12 ? 'PM' : 'AM'; - // const cron_str = new CronBuilder(cron_tab); - // this.cron_minute = - // cron_str.get('minute') === '*' - // ? this.cron_minute - // : +cron_str.get('minute'); - // this.cron_hour = - // cron_str.get('hour') === '*' - // ? this.cron_minute - // : +cron_str.get('hour'); - // if (this.cron_hour > 12) { - // this.cron_hour = this.cron_hour % 12; - // this.cron_hour_period = 'PM'; - // } else { - // this.cron_hour_period = 'AM'; - // } - // this.cron_day = - // cron_str.get('dayOfTheWeek') === '*' - // ? this.cron_day - // : this.days_of_week[+cron_str.get('dayOfTheWeek')]; - // this.cron_date = - // cron_str.get('dayOfTheMonth') === '*' - // ? this.cron_date - // : +cron_str.get('dayOfTheMonth'); - // this.cron_month = - // cron_str.get('month') === '*' - // ? this.cron_month - // : this.months_of_year[+cron_str.get('month') - 1]; - // /** Set the cron period */ - // if (cron_str.get('month') !== '*') { - // this.cron_period = 'year'; - // } else if (cron_str.get('dayOfTheMonth') !== '*') { - // this.cron_period = 'month'; - // } else if (cron_str.get('dayOfTheWeek') !== '*') { - // this.cron_period = 'week'; - // } else if (cron_str.get('hour') !== '*') { - // this.cron_period = 'day'; - // } else if (cron_str.get('minute') !== '*') { - // this.cron_period = 'hour'; - // } else { - // this.cron_period = 'minute'; - // } } } diff --git a/src/tests/triggers/triggers.utilities.spec.ts b/src/tests/triggers/triggers.utilities.spec.ts index 6e4492c65..3b7a746cb 100644 --- a/src/tests/triggers/triggers.utilities.spec.ts +++ b/src/tests/triggers/triggers.utilities.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { + buildCronString, generateTriggerActionFormModel, generateTriggerConditionFormModel, generateTriggerFormModel, generateTriggerSettingsFormModel, + parseCronString, validateEmailList, } from '../../app/triggers/triggers.utilities'; @@ -96,4 +98,38 @@ describe('triggers.utilities', () => { email: true, }); }); + + it('builds valid cron strings for each schedule period', () => { + const parts = { + minute: 30, + hour: 9, + day_of_week: 1, + day_of_month: 15, + month: 6, + }; + expect(buildCronString('day', parts)).toBe('30 9 * * *'); + expect(buildCronString('week', parts)).toBe('30 9 * * 1'); + expect(buildCronString('month', parts)).toBe('30 9 15 * *'); + expect(buildCronString('year', parts)).toBe('30 9 15 6 *'); + expect(buildCronString('custom', parts)).toBeNull(); + }); + + it.each([ + ['* * * * *', 'minute'], + ['30 * * * *', 'hour'], + ['30 9 * * *', 'day'], + ['30 9 * * 0', 'week'], + ['30 9 15 * *', 'month'], + ['30 9 15 6 *', 'year'], + ] as const)('round-trips %s as a %s schedule', (cron, period) => { + const parsed = parseCronString(cron); + expect(parsed.period).toBe(period); + expect(buildCronString(parsed.period, parsed.parts)).toBe(cron); + }); + + it('treats ranges, steps and lists as custom', () => { + for (const cron of ['*/5 * * * *', '0 9 * * 1-5', '0 9,17 * * *']) { + expect(parseCronString(cron).period).toBe('custom'); + } + }); }); From 9035955a6a8eb9e1e9cba21d289c45c2fc7acd67 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:01:08 +1000 Subject: [PATCH 02/19] fix(triggers): repair instance list, mailer reorder and zone removal - Instance delete passed the trigger instead of the row, and the state dot read `bookable` instead of `enabled`. - Mailer reorder emitted the 'function' type. - Zone instances were removed with the system API. Remove the trigger from the zone's trigger list instead. - Derive the instance tab count from the state service so it refreshes. - System trigger form used a missing `Trigger.*` locale key and allowed repeat submits while saving. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../systems/system-trigger-form.component.ts | 4 ++- src/app/triggers/trigger-about.component.ts | 2 +- .../triggers/trigger-instances.component.ts | 6 ++--- src/app/triggers/trigger-state.service.ts | 25 +++++++++++++----- src/app/triggers/triggers.component.ts | 26 +++++-------------- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/app/systems/system-trigger-form.component.ts b/src/app/systems/system-trigger-form.component.ts index ffbbf4271..65c8b756f 100644 --- a/src/app/systems/system-trigger-form.component.ts +++ b/src/app/systems/system-trigger-form.component.ts @@ -205,7 +205,7 @@ export class SystemTriggerFormComponent extends AsyncHandler implements OnInit { ); public readonly form = form(this.formModel); public loading: string; - public heading = i18n(`Trigger.${this._data.item.id ? 'EDIT' : 'NEW'}`); + public heading = i18n(`TRIGGERS.${this._data.item.id ? 'EDIT' : 'NEW'}`); public readonly trigger_state = this.formModel.asReadonly(); /** Playlist names keyed by playlist ID. IDs without a name show the ID. */ @@ -244,6 +244,8 @@ export class SystemTriggerFormComponent extends AsyncHandler implements OnInit { } public async submit(): Promise { + // Caller sets `loading` while it saves. Ignore repeat submits. + if (this.loading) return; await submit(this.form, async () => undefined); if (this.form().invalid()) { return notifyError( diff --git a/src/app/triggers/trigger-about.component.ts b/src/app/triggers/trigger-about.component.ts index ac0811d30..a668c5c6f 100644 --- a/src/app/triggers/trigger-about.component.ts +++ b/src/app/triggers/trigger-about.component.ts @@ -249,7 +249,7 @@ import { TriggerStateService } from './trigger-state.service'; }, ]" [can_reorder]="true" - (ondrop)="confirmReorder('function', $event)" + (ondrop)="confirmReorder('mailer', $event)" [empty_message]="'TRIGGERS.ACTION_EMAIL_EMPTY' | translate" /> diff --git a/src/app/triggers/trigger-instances.component.ts b/src/app/triggers/trigger-instances.component.ts index b568759af..ef4dab761 100644 --- a/src/app/triggers/trigger-instances.component.ts +++ b/src/app/triggers/trigger-instances.component.ts @@ -53,8 +53,8 @@ import { TriggerStateService } from './trigger-state.service';
@@ -83,7 +83,7 @@ import { TriggerStateService } from './trigger-state.service'; error matRipple [matTooltip]="'TRIGGERS.DELETE_INSTANCE' | translate" - (click)="deleteTrigger(item)" + (click)="deleteTrigger(row)" > delete diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts index c85af8203..611811adc 100644 --- a/src/app/triggers/trigger-state.service.ts +++ b/src/app/triggers/trigger-state.service.ts @@ -10,7 +10,9 @@ import { TriggerTimeCondition, listTriggerInstances, removeSystemTrigger, + showZone, updateTrigger, + updateZone, } from '@placeos/ts-client'; import { escapeHtml } from '../common/general'; @@ -310,12 +312,23 @@ export class TriggerStateService { ); if (details.reason !== 'done') return; details.loading(i18n('TRIGGERS.REMOVE_INSTANCE_LOADING', { type })); - const method = - type === 'zone' ? removeSystemTrigger : removeSystemTrigger; - let err: unknown = await method( - instance.control_system_id, - instance?.id || this.active_item.id, - ).catch((_) => ({ error: _ })); + // Zone instances come from the zone's trigger list, so remove it there + const remove = instance.zone_id + ? async () => { + const zone = await showZone(instance.zone_id); + await updateZone(zone.id, { + ...zone, + triggers: (zone.triggers || []).filter( + (id) => id !== this.active_item.id, + ), + }); + } + : () => + removeSystemTrigger( + instance.control_system_id, + instance?.id || this.active_item.id, + ); + let err: unknown = await remove().catch((_) => ({ error: _ })); details.close(); if ((err as Record)?.error) { err = (err as Record).error; diff --git a/src/app/triggers/triggers.component.ts b/src/app/triggers/triggers.component.ts index c2ddf4111..7ec53f308 100644 --- a/src/app/triggers/triggers.component.ts +++ b/src/app/triggers/triggers.component.ts @@ -1,8 +1,8 @@ -import { Component, computed, effect, inject, 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 { RouterModule } from '@angular/router'; -import { listTriggerInstances, PlaceTrigger } from '@placeos/ts-client'; +import { PlaceTrigger } from '@placeos/ts-client'; import { extensionsForItem } from '../common/api'; import { AsyncHandler } from '../common/async-handler.class'; import { PlaceDebugService } from '../common/debug.service'; @@ -18,6 +18,7 @@ import { ItemSidebarComponent } from '../ui/item-sidebar.component'; import { ItemTablistComponent } from '../ui/item-tablist.component'; import { SidebarMenuComponent } from '../ui/sidebar-menu.component'; import { TranslatePipe } from '../ui/translate.pipe'; +import { TriggerStateService } from './trigger-state.service'; @Component({ selector: 'new-triggers-view', @@ -111,11 +112,14 @@ import { TranslatePipe } from '../ui/translate.pipe'; export class TriggersComponent extends AsyncHandler { protected _service = inject(ActiveItemService); private _debug = inject(PlaceDebugService); + private _state = inject(TriggerStateService); public readonly name = 'triggers'; public open_menu = false; - public readonly instance_count = signal(0); + public readonly instance_count = computed( + () => this._state.instances().length, + ); public readonly loading = toSignal(this._service.loading, { initialValue: false, }); @@ -140,20 +144,4 @@ export class TriggersComponent extends AsyncHandler { }, ].concat(extensionsForItem(this.item(), this.name)), ); - - constructor() { - super(); - effect(() => { - void this.loadValues(this.item() as PlaceTrigger | null); - }); - } - - protected async loadValues(item: PlaceTrigger | null) { - if (!item) { - this.instance_count.set(0); - return; - } - // Get trigger count - this.instance_count.set((await listTriggerInstances(item.id)).length); - } } From 381c3a0ca1b2b86e693866eb92e3cb8184e87253 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:02:44 +1000 Subject: [PATCH 03/19] fix(drivers): repair module removal, update checks and driver form - Remove driver modules with removeModule and reload the list. - Show the update badge when any update exists, refresh it after an update, and replace the active driver with the saved response. - Update list modal: sync total with the filtered list so select all and auto close work. - Driver form: fall back to the raw commit id when it is not in the fetched list, always clear the spinner, and pre-select the repository and driver for new drivers. - Docs tab: show the empty state after the readme loads instead of redirecting deep links away. - Repository drivers: pass the driver row to the new driver form and stop navigating away when the list is empty. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/drivers/driver-docs.component.ts | 20 +++------ src/app/drivers/driver-form.component.ts | 43 ++++++++++++------- src/app/drivers/driver-state.service.ts | 41 ++++++++++-------- .../driver-update-list-modal.component.ts | 2 + .../repository-drivers.component.ts | 37 +++------------- 5 files changed, 63 insertions(+), 80 deletions(-) diff --git a/src/app/drivers/driver-docs.component.ts b/src/app/drivers/driver-docs.component.ts index 7d675a3ea..88658e5e6 100644 --- a/src/app/drivers/driver-docs.component.ts +++ b/src/app/drivers/driver-docs.component.ts @@ -1,8 +1,7 @@ import { AsyncPipe } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { DriverStateService } from './driver-state.service'; -import { Router } from '@angular/router'; import { IconComponent } from '../ui/icon.component'; import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; @@ -16,7 +15,7 @@ import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; class="markdown items-start" [innerHTML]="docs_string | markdown | async" > - } @else { + } @else if (!docs_loading()) {
@@ -29,19 +28,10 @@ import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; styles: [``], imports: [MarkdownPipe, IconComponent, AsyncPipe], }) -export class DriverDocsComponent implements OnInit { +export class DriverDocsComponent { private _service = inject(DriverStateService); - private _router = inject(Router); public readonly docs = this._service.docs; - - public async ngOnInit() { - const str = this.docs(); - if (str) return; - this._router.navigate([ - '/drivers', - this._service.active_item.id, - 'about', - ]); - } + /** Hide the empty state until the readme request settles */ + public readonly docs_loading = this._service.docs_loading; } diff --git a/src/app/drivers/driver-form.component.ts b/src/app/drivers/driver-form.component.ts index 53e2f1075..9e62fa054 100644 --- a/src/app/drivers/driver-form.component.ts +++ b/src/app/drivers/driver-form.component.ts @@ -548,24 +548,37 @@ export class DriverFormComponent extends AsyncHandler implements OnInit { this.loading.set(''); } + /** + * Load the repository, driver and commit for an existing driver. + * For a new driver, pre-select the repository and driver if given. + */ private async _loadDetailsFromForm() { const { id, commit, file_name, repository_id } = this.formModel(); - if (!id) return; + if (!repository_id) return; this.loading.set('DRIVERS.DETAILS_LOADING'); - const repo = await showRepository(repository_id); - const driver = { - id: file_name, - name: file_name.replace(/\//g, ' > '), - }; - this.repo.set(repo); - this.driver.set(driver); - const commit_list = await this._loadCommitList( - repository_id, - file_name, - ); - const active_commit = commit_list.find((c) => c.id === commit); - if (active_commit) this.commit.set(active_commit); - this.loading.set(''); + try { + const repo = await showRepository(repository_id); + this.repo.set(repo); + if (!file_name) return; + this.driver.set({ + id: file_name, + name: file_name.replace(/\//g, ' > '), + }); + if (!id) return; + const commit_list = await this._loadCommitList( + repository_id, + file_name, + ); + // Commit may be older than the fetched list + const active_commit = commit_list.find((c) => c.id === commit) || { + id: commit, + name: commit, + extra: null, + }; + this.commit.set(active_commit); + } finally { + this.loading.set(''); + } } private async newSettings(item: Identity, settings_string: string) { diff --git a/src/app/drivers/driver-state.service.ts b/src/app/drivers/driver-state.service.ts index f352c50bf..5e3205f3b 100644 --- a/src/app/drivers/driver-state.service.ts +++ b/src/app/drivers/driver-state.service.ts @@ -8,7 +8,7 @@ import { queryModules, recompileDriver, reloadDriver, - removeSystemModule, + removeModule, updateDriver, } from '@placeos/ts-client'; import { ActiveItemService } from '../common/item.service'; @@ -26,6 +26,7 @@ export class DriverStateService { private _loading = signal(false); private _last_error = signal(null); private _poll = signal(0); + private _modules_change = signal(0); public readonly item = computed( () => this._state.item() as unknown as PlaceDriver, @@ -40,7 +41,7 @@ export class DriverStateService { update_available: true, limit: 1, }).catch(() => ({ total: 0 })); - return response.total > 1; + return response.total > 0; }, }); @@ -49,8 +50,8 @@ export class DriverStateService { ); private readonly _modules = resource({ - params: () => this.item(), - loader: async ({ params: item }) => { + params: () => ({ item: this.item(), changed: this._modules_change() }), + loader: async ({ params: { item } }) => { if (!(item instanceof PlaceDriver)) return [] as PlaceModule[]; this._loading.set(true); try { @@ -80,6 +81,7 @@ export class DriverStateService { }); public readonly docs = computed(() => this._docs.value() || ''); + public readonly docs_loading = computed(() => this._docs.isLoading()); public get active_item() { return this._state.active_item; @@ -113,12 +115,15 @@ export class DriverStateService { ); if (details.reason !== 'done') return details.close(); details.loading('Updating driver...'); - const success = await updateDriver(item.id, { + const updated = await updateDriver(item.id, { ...item, commit: item.update_info.commit, }).catch(() => null); - if (!success) { + if (!updated) { notifyError('Failed to update driver.'); + } else { + this._state.replaceItem(updated as unknown as Identity); + this._poll.update((poll) => poll + 1); } details.close(); } @@ -187,19 +192,19 @@ export class DriverStateService { this._dialog, ); if (details.reason !== 'done') return; - const system = await removeSystemModule( - this.active_item.id, - device.id, - ).catch((err) => { - notifyError( - `Error removing module ${device.id}. Error: ${ - err.statusText || err.message || err - }`, - ); - }); + const removed = await removeModule(device.id) + .then(() => true) + .catch((err) => { + notifyError( + `Error removing module ${device.id}. Error: ${ + err.statusText || err.message || err + }`, + ); + return false; + }); details.close(); - if (!system) return; - this._state.replaceItem(system as unknown as Identity); + if (!removed) return; + this._modules_change.update((change) => change + 1); notifySuccess(`Successfully removed module.`); } } diff --git a/src/app/drivers/driver-update-list-modal.component.ts b/src/app/drivers/driver-update-list-modal.component.ts index d61bba91f..e2bfb4ac1 100644 --- a/src/app/drivers/driver-update-list-modal.component.ts +++ b/src/app/drivers/driver-update-list-modal.component.ts @@ -180,6 +180,8 @@ export class DriverUpdateListModalComponent { response.data = response.data.sort((a, b) => a.name.localeCompare(b.name), ); + // Keep total in sync with the filtered list for select all + response.total = response.data.length; this.selected_drivers.set(response.data.map((d) => d.id)); this.loading.set(''); return response; diff --git a/src/app/repositories/repository-drivers.component.ts b/src/app/repositories/repository-drivers.component.ts index 32ddb6069..0ba53d5eb 100644 --- a/src/app/repositories/repository-drivers.component.ts +++ b/src/app/repositories/repository-drivers.component.ts @@ -1,11 +1,8 @@ -import { Component, effect, inject, OnInit } from '@angular/core'; -import { PlaceRepository } from '@placeos/ts-client'; +import { Component, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; -import { AsyncHandler } from '../common/async-handler.class'; import { IconComponent } from '../ui/icon.component'; import { DriverFormatPipe } from '../ui/pipes/driver-format.pipe'; import { SimpleTableComponent } from '../ui/simple-table.component'; @@ -75,7 +72,7 @@ import { RepositoriesStateService } from './repositories-state.service'; matRipple [matTooltip]="'DRIVERS.NEW' | translate" matTooltipPosition="left" - (click)="newDriver(item)" + (click)="newDriver(row)" > add @@ -100,9 +97,8 @@ import { RepositoriesStateService } from './repositories-state.service'; MatRippleModule, ], }) -export class RepositoryDriversComponent extends AsyncHandler implements OnInit { +export class RepositoryDriversComponent { private _service = inject(RepositoriesStateService); - private _router = inject(Router); /** Whether driver list is loading */ public readonly loading = this._service.loading; @@ -110,29 +106,6 @@ export class RepositoryDriversComponent extends AsyncHandler implements OnInit { public readonly driver_list = this._service.driver_list; public readonly driver_list_error = this._service.driver_list_error; - public get item(): PlaceRepository { - return this._service.active_item as PlaceRepository; - } - - public readonly newDriver = (d) => this._service.newDriver(d); - - constructor() { - super(); - effect(() => { - if (this.driver_list()?.length || this.driver_list_error()) { - this.clearTimeout('has_drivers'); - } - }); - } - - public ngOnInit() { - this.timeout( - 'has_drivers', - () => this._router.navigate(['/repositories', this.item.id]), - 3000, - ); - if (this.driver_list()?.length || this.driver_list_error()) { - this.clearTimeout('has_drivers'); - } - } + public readonly newDriver = (driver: string) => + this._service.newDriver(driver); } From 5e0c20bf42c7eee783d2f5c6a377600fb06fea9b Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:05:08 +1000 Subject: [PATCH 04/19] fix(systems): keep tab state per system and stop form resets - Zones tab: make the reorder flag a signal so a reorder alone can be saved, mark the moved zone, and clear pending changes on system change. - Triggers tab: drop the temp trigger merge that left ghost rows. The service already reloads the list. - Modules tab: exclude modules already in the system by id and clear the pending module on system change. - About tab: show the Public row based on `public`, not `email`. - Module form: apply driver defaults once per driver so user edits stick. - Module and system forms ignore repeat submits while saving. - Module state: capture the active module before awaiting start/stop. - Show the real module count in module and zone system lists. - Modules view: import MatRippleModule instead of a duplicate icon. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/modules/module-form.component.ts | 68 +++++++++---------- src/app/modules/module-state.service.ts | 20 +++--- src/app/modules/module-systems.component.ts | 7 +- src/app/modules/modules.component.ts | 3 +- src/app/systems/system-about.component.ts | 2 +- src/app/systems/system-form.component.ts | 1 + src/app/systems/system-modules.component.ts | 23 ++++++- src/app/systems/system-triggers.component.ts | 17 ++--- src/app/systems/system-zones.component.ts | 30 ++++++-- src/app/zones/zone-systems.component.ts | 7 +- .../modules/module-form.component.spec.ts | 34 ++++++++++ 11 files changed, 138 insertions(+), 74 deletions(-) diff --git a/src/app/modules/module-form.component.ts b/src/app/modules/module-form.component.ts index 5f10e016e..49505aec3 100644 --- a/src/app/modules/module-form.component.ts +++ b/src/app/modules/module-form.component.ts @@ -7,6 +7,7 @@ import { OnInit, Output, signal, + untracked, } from '@angular/core'; import { form, FormField, submit } from '@angular/forms/signals'; import { @@ -356,6 +357,12 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit { ); public readonly form = form(this.formModel, applyModuleFormSchema); public readonly loading = signal(null); + /** Selected driver. Only changes when a new driver is picked. */ + private readonly _selected_driver = computed( + () => this.formModel().driver, + ); + /** ID of the driver whose defaults have been applied to the form */ + private _defaults_driver_id = ''; public heading = i18n( `${this._name}.${this._data.item.id ? 'EDIT' : 'NEW'}`, ); @@ -443,47 +450,36 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit { })); } }); + // Apply driver defaults once per selected driver so later edits stick effect(() => { - const model = this.formModel(); - const driver = model.driver; - if (!driver?.id) return; - const role = driver.role ?? PlaceDriverRole.Logic; - const udp = - driver.role === PlaceDriverRole.Service || - driver.role === PlaceDriverRole.Websocket - ? false - : model.udp; - const system = - driver.role === PlaceDriverRole.Logic - ? model.system - : undefined; - if ( - model.driver_id === driver.id && - model.name === (driver.name || driver.module_name) && - model.uri === (driver.default_uri || '') && - model.port === (driver.default_port || 1) && - model.alert_level === (driver.alert_level || 'medium') && - model.role === role && - model.udp === udp && - model.system === system - ) { - return; - } - this.formModel.update((value) => ({ - ...value, - driver_id: driver.id, - name: driver.name || driver.module_name, - uri: driver.default_uri || '', - port: driver.default_port || 1, - alert_level: driver.alert_level || 'medium', - role, - udp, - system, - })); + const driver = this._selected_driver(); + if (!driver?.id || driver.id === this._defaults_driver_id) return; + this._defaults_driver_id = driver.id; + untracked(() => + this.formModel.update((value) => ({ + ...value, + driver_id: driver.id, + name: driver.name || driver.module_name, + uri: driver.default_uri || '', + port: driver.default_port || 1, + alert_level: driver.alert_level || 'medium', + role: driver.role ?? PlaceDriverRole.Logic, + udp: + driver.role === PlaceDriverRole.Service || + driver.role === PlaceDriverRole.Websocket + ? false + : value.udp, + system: + driver.role === PlaceDriverRole.Logic + ? value.system + : undefined, + })), + ); }); } public async submit(): Promise { + if (this.loading()) return; await submit(this.form, async () => { const item = this._data.item; this.loading.set(i18n(`${this._name}.SAVING`)); diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts index a902bc06e..95517bf29 100644 --- a/src/app/modules/module-state.service.ts +++ b/src/app/modules/module-state.service.ts @@ -94,8 +94,11 @@ export class ModuleStateService { } public async toggleModuleState() { - const method = this.active_item.running ? stopModule : startModule; - const error = await method(this.active_item.id) + // Capture before the await as the active module may change + const item = this.active_item as PlaceModule & { running: boolean }; + const running = item.running; + const method = running ? stopModule : startModule; + const error = await method(item.id) .then(() => null) .catch((err) => err); if (error) { @@ -103,9 +106,9 @@ export class ModuleStateService { notifyError(error); } else { notifyError( - `Failed to ${ - this.active_item.running ? 'stop' : 'start' - } device '${this.active_item.id}'.\nView Error?`, + `Failed to ${running ? 'stop' : 'start'} device '${ + item.id + }'.\nView Error?`, 'View', () => this.viewDetails(error), ); @@ -113,12 +116,9 @@ export class ModuleStateService { return; } notifySuccess( - `Module successfully ${ - this.active_item.running ? 'stopped' : 'started' - }`, + `Module successfully ${running ? 'stopped' : 'started'}`, ); - (this.active_item as PlaceModule & { running: boolean }).running = - !this.active_item.running; + item.running = !running; } /** View Results of the execute */ diff --git a/src/app/modules/module-systems.component.ts b/src/app/modules/module-systems.component.ts index 5aaadd622..6813b66ee 100644 --- a/src/app/modules/module-systems.component.ts +++ b/src/app/modules/module-systems.component.ts @@ -46,7 +46,7 @@ import { ModuleStateService } from './module-state.service'; content: name_template, }, { - key: 'installed_ui_devices', + key: 'module_count', name: 'MODULES.SYSTEMS_FIELD_MODULE_COUNT' | translate, @@ -117,7 +117,10 @@ export class ModuleSystemsComponent { public readonly system_list = computed(() => { const filter = this.filter().toLowerCase(); - const systems = this.systems(); + const systems = this.systems().map((sys) => ({ + ...sys, + module_count: sys.modules?.length || 0, + })); return filter ? systems.filter((sys) => sys.name.toLowerCase().includes(filter)) : systems; diff --git a/src/app/modules/modules.component.ts b/src/app/modules/modules.component.ts index e0ce1add7..9e8319ab5 100644 --- a/src/app/modules/modules.component.ts +++ b/src/app/modules/modules.component.ts @@ -1,4 +1,5 @@ import { Component, computed, effect, inject, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { PlaceModule } from '@placeos/ts-client'; @@ -98,7 +99,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; imports: [ DebugOutputComponent, IconComponent, - IconComponent, + MatRippleModule, TranslatePipe, MatTooltipModule, RouterModule, diff --git a/src/app/systems/system-about.component.ts b/src/app/systems/system-about.component.ts index b6fa10308..28caf6b80 100644 --- a/src/app/systems/system-about.component.ts +++ b/src/app/systems/system-about.component.ts @@ -52,7 +52,7 @@ import { SystemStateService } from './system-state.service'; {{ 'COMMON.TRUE' | translate }}
} - @if (item()?.email) { + @if (item()?.public != null) {
{{ 'SYSTEMS.PUBLIC' | translate }}
diff --git a/src/app/systems/system-form.component.ts b/src/app/systems/system-form.component.ts index 50fdab4d2..6f3dc4ff5 100644 --- a/src/app/systems/system-form.component.ts +++ b/src/app/systems/system-form.component.ts @@ -667,6 +667,7 @@ export class SystemFormComponent extends AsyncHandler implements OnInit { } public async submit(): Promise { + if (this.loading()) return; await submit(this.form, async () => { const item = this._data.item as unknown as PlaceResource; this.loading.set(i18n(`${this._name}.SAVING`)); diff --git a/src/app/systems/system-modules.component.ts b/src/app/systems/system-modules.component.ts index 60f840ff6..e86a9b925 100644 --- a/src/app/systems/system-modules.component.ts +++ b/src/app/systems/system-modules.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; @@ -393,6 +400,7 @@ export class SystemModulesComponent extends AsyncHandler { public readonly item_signal = computed( () => this._service.item() as PlaceSystem, ); + private readonly _item_id = computed(() => this.item_signal()?.id); public readonly loading = this._service.loading; public readonly modules = this._service.modules; public readonly modules_refresh = computed(() => { @@ -498,10 +506,10 @@ export class SystemModulesComponent extends AsyncHandler { ); /** Function for excluding modules already within this system */ public readonly exclude_fn = ( - item: PlaceModule & { control_system_id?: string; role?: number }, + item: PlaceModule & { role?: number }, __: string, ) => - item.control_system_id === this.item.id || + !!this.item.modules?.includes(item.id) || item.role === PlaceDriverRole.Logic; public readonly newModule = () => this._service.newModule(); @@ -530,6 +538,15 @@ export class SystemModulesComponent extends AsyncHandler { return `${i18n('DRIVERS.UNKNOWN')} (${role})`; } + constructor() { + super(); + // Tab is reused across systems. Drop the pending module on change. + effect(() => { + this._item_id(); + untracked(() => this.new_module.set('')); + }); + } + public get item(): PlaceSystem { return this._service.active_item as PlaceSystem; } diff --git a/src/app/systems/system-triggers.component.ts b/src/app/systems/system-triggers.component.ts index ee8480683..ced168383 100644 --- a/src/app/systems/system-triggers.component.ts +++ b/src/app/systems/system-triggers.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, model, signal } from '@angular/core'; +import { Component, computed, inject, model } from '@angular/core'; import { PlaceTrigger } from '@placeos/ts-client'; import { copyToClipboard, unique } from '../common/general'; import { notifyInfo } from '../common/notifications'; @@ -229,13 +229,9 @@ export class SystemTriggersComponent { public readonly comparisons: HashMap = {}; - public readonly temp_trigger = signal(null); - public readonly triggers = computed(() => { const filter = this.filter().toLowerCase(); - const temp = this.temp_trigger(); - const triggers = this.service_triggers(); - const list = unique(temp ? [...triggers, temp] : triggers, 'id'); + const list = unique(this.service_triggers(), 'id'); return filter ? list.filter((t) => (t as PlaceTrigger & { name: string }).name @@ -251,13 +247,10 @@ export class SystemTriggersComponent { ); notifyInfo(i18n('SYSTEMS.COPIED_WEBHOOK')); }; - public readonly editTrigger = async (t) => - this.temp_trigger.set( - (await this._service.editTrigger(t)) as PlaceTrigger, - ); + // The service reloads the trigger list after each change + public readonly editTrigger = (t) => this._service.editTrigger(t); public readonly deleteTrigger = (t) => this._service.removeTrigger(t); - public readonly selectTrigger = async () => - this.temp_trigger.set((await this._service.selectTrigger()) || null); + public readonly selectTrigger = () => this._service.selectTrigger(); public get item() { return this._service.active_item; diff --git a/src/app/systems/system-zones.component.ts b/src/app/systems/system-zones.component.ts index 957b1355a..416f66c08 100644 --- a/src/app/systems/system-zones.component.ts +++ b/src/app/systems/system-zones.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { PlaceSystem, PlaceZone } from '@placeos/ts-client'; import { querySupportZones as queryZones } from '../common/support-access'; @@ -177,13 +184,14 @@ import { SystemStateService } from './system-state.service'; export class SystemZonesComponent { private _service = inject(SystemStateService); - public order_changed = false; + public readonly order_changed = signal(false); public show_original = false; public readonly original_zones = this._service.zones; public readonly item_signal = this._service.item; + private readonly _item_id = computed(() => this.item_signal()?.id); public changed: Record = {}; /** ID of a zone that the user wishes to add to the system */ @@ -236,7 +244,7 @@ export class SystemZonesComponent { }); public readonly has_changes = computed( - () => this.pending_zones().length > 0 || this.order_changed, + () => this.pending_zones().length > 0 || this.order_changed(), ); /** Query function for systems */ @@ -267,7 +275,7 @@ export class SystemZonesComponent { const zone_order = this.zone_order(); if (zones.every(({ id }, idx) => zone_order[idx] === id)) return; await this._service.reorderZones(zone_order); - this.order_changed = false; + this.order_changed.set(false); this.changed = {}; this.zone_order.set([]); }; @@ -278,12 +286,20 @@ export class SystemZonesComponent { } public clearChanges() { - this.order_changed = false; + this.order_changed.set(false); this.changed = {}; this.zone_order.set([]); this.pending_zones.set([]); } + constructor() { + // Tab is reused across systems. Drop unsaved changes on system change. + effect(() => { + this._item_id(); + untracked(() => this.clearChanges()); + }); + } + public get item(): PlaceSystem { return this._service.active_item as PlaceSystem; } @@ -291,8 +307,8 @@ export class SystemZonesComponent { public async reorder([previous, current]: [number, number]) { const zones = [...this.zones()]; moveItemInArray(zones, previous, current); - this.changed[zones[previous].id] = true; + this.changed[zones[current].id] = true; this.zone_order.set(zones.map(({ id }) => id)); - this.order_changed = true; + this.order_changed.set(true); } } diff --git a/src/app/zones/zone-systems.component.ts b/src/app/zones/zone-systems.component.ts index 4403fd5a1..765244316 100644 --- a/src/app/zones/zone-systems.component.ts +++ b/src/app/zones/zone-systems.component.ts @@ -45,7 +45,7 @@ import { ZonesStateService } from './zones-state.service'; content: name_template, }, { - key: 'installed_ui_devices', + key: 'module_count', name: 'ZONES.SYSTEMS_FIELD_MODULE_COUNT' | translate, size: '10rem', }, @@ -108,7 +108,10 @@ export class ZoneSystemsComponent { public readonly systems = computed(() => { const filter = this.filter().toLowerCase(); - const systems = this._systems(); + const systems = this._systems().map((sys) => ({ + ...sys, + module_count: sys.modules?.length || 0, + })); return !filter ? systems : systems.filter((sys) => sys.name.toLowerCase().includes(filter)); diff --git a/src/tests/modules/module-form.component.spec.ts b/src/tests/modules/module-form.component.spec.ts index e3566cdcd..3ecba6e38 100644 --- a/src/tests/modules/module-form.component.spec.ts +++ b/src/tests/modules/module-form.component.spec.ts @@ -161,6 +161,40 @@ describe('creating a module', () => { ); }); + it('should keep user edits after applying driver defaults', async () => { + const instance = fixture.componentInstance; + instance.formModel.update((value) => ({ + ...value, + driver: { + id: 'driver-device', + name: 'Device driver', + role: PlaceDriverRole.Device, + default_uri: 'tcp://default', + default_port: 4999, + } as PlaceDriver, + })); + await fixture.whenStable(); + expect(instance.formModel()).toMatchObject({ + driver_id: 'driver-device', + name: 'Device driver', + uri: 'tcp://default', + port: 4999, + }); + + instance.formModel.update((value) => ({ + ...value, + name: 'Custom', + uri: 'tcp://custom', + port: 1234, + })); + await fixture.whenStable(); + expect(instance.formModel()).toMatchObject({ + name: 'Custom', + uri: 'tcp://custom', + port: 1234, + }); + }); + it.each([ PlaceDriverRole.Device, PlaceDriverRole.SSH, From 5875063ac689aac9cd41b776fc75cd9937b8f914 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:07:57 +1000 Subject: [PATCH 05/19] fix(zones): load full lists and fix user, group and zone forms - Users: read `items` from the bulk add groups result and type the dialog, so bulk adding groups works again. - Zones: page through systems and child zones so the lists match the tab counts, hide admin-only tabs for non-admins, show the timezone empty state from the filtered list, and clear the selected system when the zone changes. - Groups: add a control to clear the parent group and lock the domain when editing an existing group. Co-Authored-By: Claude Opus 5.5 (1M context) --- public/assets/locale/en-AU.json | 1 + src/app/common/support-access.ts | 3 +- src/app/groups/group-form.component.ts | 42 ++++++++++++++++++++------ src/app/users/users-state.service.ts | 18 +++++++++-- src/app/zones/zone-about.component.ts | 15 ++++++++- src/app/zones/zone-form.component.ts | 2 +- src/app/zones/zones-state.service.ts | 17 ++++++----- src/app/zones/zones.component.ts | 7 +++++ 8 files changed, 81 insertions(+), 24 deletions(-) diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 479f8f444..21609f87c 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -758,6 +758,7 @@ "AUTHORITY_SELECT": "Select domain", "PARENT_ID": "Parent Group", "PARENT_SEARCH": "Search for parent group...", + "PARENT_CLEAR": "Remove parent group", "SUBSYSTEMS": "Subsystems", "SUBSYSTEMS_HINT": "Comma separated subsystems", "SUBSYSTEMS_EMPTY": "No subsystems", diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts index 97b31aeae..4b9d08782 100644 --- a/src/app/common/support-access.ts +++ b/src/app/common/support-access.ts @@ -77,7 +77,8 @@ export function canAccessSection(section: string) { return false; } -async function collectPages(response: QueryResponse) { +/** Load every page of a query. Throws after 1000 extra pages. */ +export async function collectPages(response: QueryResponse) { let page = await response; const data = [...page.data]; for (let i = 0; page.next && i < 1000; i++) { diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts index 213b622c9..bd9bd8ab1 100644 --- a/src/app/groups/group-form.component.ts +++ b/src/app/groups/group-form.component.ts @@ -10,7 +10,7 @@ import { signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { form, FormField, submit } from '@angular/forms/signals'; +import { disabled, form, FormField, submit } from '@angular/forms/signals'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -104,14 +104,32 @@ import { - +
+ + @if (formModel().parent_id) { + + } +
- {{ mappedFieldCount() }} / {{ field_list().length }} + {{ mapped_field_count() }} / {{ field_list().length }}
@@ -138,6 +139,8 @@ export class MatchFieldsComponent implements OnChanges, OnInit { const mappings = this.mappings(); const list = this.list(); if (changes.list && list && list.length) { + // Mappings from a previous file do not apply to a new one + this.field_mapping.set({}); this.source_fields.set( Object.keys(list[0]).map((i) => ({ id: i, @@ -184,11 +187,11 @@ export class MatchFieldsComponent implements OnChanges, OnInit { })); } - public mappedFieldCount(): number { - return this.field_list().filter( - (field) => !!this.field_mapping[field.id], - ).length; - } + public readonly mapped_field_count = computed(() => { + const mapping = this.field_mapping(); + return this.field_list().filter((field) => !!mapping[field.id]) + .length; + }); public isRequired(field: unknown): boolean { return this.required_fields().includes(`${field}`); diff --git a/src/app/overlays/duplicate-modal.component.ts b/src/app/overlays/duplicate-modal.component.ts index e3561461e..e006d8772 100644 --- a/src/app/overlays/duplicate-modal.component.ts +++ b/src/app/overlays/duplicate-modal.component.ts @@ -9,6 +9,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef, + MatDialogState, } from '@angular/material/dialog'; import { FormsModule } from '@angular/forms'; @@ -43,6 +44,9 @@ export interface DuplicateModalData { matInput name="times" type="number" + min="1" + [max]="MAX_TIMES" + step="1" [(ngModel)]="times" placeholder="Number of duplications" required @@ -55,7 +59,7 @@ export interface DuplicateModalData { @if (!done()) {
Creating item duplicates...
} - @for (itm of temp(); track itm.id; let i = $index) { + @for (itm of temp(); track $index; let i = $index) {
{{ item.name }} ({{ i + 1 }}) @@ -85,7 +89,7 @@ export interface DuplicateModalData {
- @if (!online && !loading()) { + @if (!online() && !loading()) {
@@ -143,9 +145,8 @@ export class AppComponent extends AsyncHandler implements OnInit { return this._users.dark_mode; } - public get online() { - return isOnline(); - } + /** Whether PlaceOS is reachable */ + public readonly online = signalFromClient(onlineState()); public get is_fools_day(): boolean { return false; @@ -186,16 +187,12 @@ export class AppComponent extends AsyncHandler implements OnInit { this.timeout('wait_for_user', () => this.onInitError(), 30 * 1000); await waitForSignalValue(this._users.initialised, (_) => _); this.clearTimeout('wait_for_user'); + setLoadingMessage('Initialising locales...'); + // TranslatePipe is pure, so load translations before the shell renders + await this._initLocale(); this.loading.set(false); setLoadingMessage('Initialising upload service...'); - this.timeout('init_uploads', () => { - initUploads({ - auto_start: true, - token: token(), - endpoint: '/api/engine/v2/uploads', - worker_url: 'assets/md5_worker.js', - }); - }); + this.timeout('init_uploads', () => syncUploadToken()); // this.interval( // 'dark-mode', // () => @@ -212,8 +209,6 @@ export class AppComponent extends AsyncHandler implements OnInit { }); setLoadingMessage('Checking staff tenants...'); this._checkTenants(); - setLoadingMessage('Initialising locales...'); - this._initLocale(); } private onInitError() { @@ -224,29 +219,22 @@ export class AppComponent extends AsyncHandler implements OnInit { location.reload(); } + /** Show one banner for staff tenants with expiring secrets */ private async _checkTenants() { if (!currentUser()?.sys_admin) return; - const tenant_list: PlaceTenant[] = ( - await get('/api/staff/v1/tenants') - ).map((_) => Object.keys(_).map((i) => _[i] as PlaceTenant)); - for (const tenant of tenant_list) { - if (!tenant.secret_expiry) continue; - if (tenant.secret_expiry > getUnixTime(addDays(Date.now(), -30))) { - this._settings.post('banner', { - id: `tenant_secret_expiry-${tenant.id}`, - type: 'warn', - content: `Staff API Tenant "${ - tenant.name - }" has a secret that will expire on ${format( - tenant.secret_expiry * 1000, - "MMM do 'at' h:mma", - )}.`, - }); - } - } + const tenants = await get('/api/staff/v1/tenants').catch(() => []); + const banner = tenantExpiryBanner( + Array.isArray(tenants) ? (tenants as PlaceTenant[]) : [], + ); + if (banner) this._settings.post('banner', banner); } - private _initLocale() { + /** + * Set the locale from storage or the browser languages. + * Resolves when translations load, or after a timeout. + */ + private async _initLocale() { + let load: Promise | undefined; try { let locale = localStorage.getItem('BACKOFFICE.locale'); const locales = (this._settings.get('app.locales') as { @@ -254,16 +242,16 @@ export class AppComponent extends AsyncHandler implements OnInit { name: string; }[]) || [{ id: 'en', name: 'English' }]; if (locale) { - this._locale?.setLocale(locale); + load = this._locale?.setLocale(locale); } else { - const list = navigator.languages; + const list = navigator.languages || []; for (const lang of list) { locale = locales.find((_) => _.id === lang)?.id; if (!locale) locale = locales.find((_) => lang.includes(_.id))?.id; if (locale) { - this._locale?.setLocale(lang); - localStorage.setItem('BACKOFFICE.locale', lang); + load = this._locale?.setLocale(locale); + localStorage.setItem('BACKOFFICE.locale', locale); break; } } @@ -271,5 +259,11 @@ export class AppComponent extends AsyncHandler implements OnInit { } catch { // Ignore locale parsing errors } + if (!load) return; + // Do not block start up on a slow or failed locale file + await Promise.race([ + load.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOCALE_TIMEOUT_MS)), + ]); } } diff --git a/src/app/common/locale.service.ts b/src/app/common/locale.service.ts index b6d3b75ca..477d74486 100644 --- a/src/app/common/locale.service.ts +++ b/src/app/common/locale.service.ts @@ -166,7 +166,11 @@ export class LocaleService { return this._current_locale_short; } - public setLocale(locale: string) { + /** + * Set the active locale. + * Resolves when the locale mappings have loaded. + */ + public setLocale(locale: string): Promise { this._current_locale = locale; this._current_locale_short = this._current_locale.split('-')[0]; if (!this._locale_mappings[locale] && !this._load_promises[locale]) { @@ -174,6 +178,7 @@ export class LocaleService { } localStorage.setItem(`${STORE_KEY}`, locale); log('LOCALE', `Locale set to "${locale}"`); + return this._load_promises[locale] || Promise.resolve(); } private async _loadLocale(locale: string) { diff --git a/src/app/common/uploads.ts b/src/app/common/uploads.ts index ad4b9ebe3..c4b500bb3 100644 --- a/src/app/common/uploads.ts +++ b/src/app/common/uploads.ts @@ -1,13 +1,35 @@ import { humanReadableByteCount, + initUploads, uploadFile as uploadNewFile, } from '@placeos/cloud-uploads'; +import { token } from '@placeos/ts-client'; import { randomInt } from './general'; import { SubscriptionLike } from './signals'; import * as blobUtil from 'blob-util'; +/** Token last passed to the upload service */ +let upload_token = ''; + +/** + * Initialise the upload service with the current PlaceOS token. + * Call before each upload. The upload library has no token setter, so it is + * initialised again only when the token has changed since the last call. + */ +export function syncUploadToken() { + const current_token = token(); + if (!current_token || current_token === upload_token) return; + upload_token = current_token; + initUploads({ + auto_start: true, + token: current_token, + endpoint: '/api/engine/v2/uploads', + worker_url: 'assets/md5_worker.js', + }); +} + function uploadURL(id: string) { return `${location.origin}/api/engine/v2/uploads/${encodeURIComponent( id, @@ -66,6 +88,7 @@ export function uploadFile( arrayBuffer as ArrayBuffer, file.type, ); + syncUploadToken(); const upload = await uploadNewFile(file, { permissions, public: is_public, From 5dd4b8e8614a888cfe6b4e478adcce0c84012adf Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:16:21 +1000 Subject: [PATCH 10/19] fix(app): load full browser locale tag again Matching locale ids such as 'en' have no locale file, so loading the matched id fell back to bundled en-AU text for every browser. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/app.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/app.ts b/src/app/app.ts index b16563f53..466c77f06 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -249,9 +249,11 @@ export class AppComponent extends AsyncHandler implements OnInit { locale = locales.find((_) => _.id === lang)?.id; if (!locale) locale = locales.find((_) => lang.includes(_.id))?.id; + // Load the full browser tag (e.g. en-US), not the matched id, + // as locale files use full tags. if (locale) { - load = this._locale?.setLocale(locale); - localStorage.setItem('BACKOFFICE.locale', locale); + load = this._locale?.setLocale(lang); + localStorage.setItem('BACKOFFICE.locale', lang); break; } } From b6599b22a9f2afbcb149e8c1517fad2e4b3cdb51 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 12:22:34 +1000 Subject: [PATCH 11/19] fix(triggers): save custom cron at once and stop date fields submitting the form - Custom cron wrote to the form after 1s, so a quick Save stored '* * * * *'. - Buttons in custom fields had no type, so the date field submitted the condition form natively and reloaded the page. Set type="button" and prevent submit on the condition form. - The remove instance confirm showed an empty name. Use the zone or system id, as the instance list does. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/triggers/trigger-state.service.ts | 7 ++++++- src/app/ui/custom-fields/date-calendar.component.ts | 3 +++ src/app/ui/custom-fields/date-field.component.ts | 1 + .../ui/custom-fields/item-search-field.component.ts | 1 + .../ui/custom-fields/object-list-field.component.ts | 1 + src/app/ui/custom-fields/time-field.component.ts | 1 + .../trigger-condition-form/time-form.component.ts | 11 ++--------- src/app/ui/forms/trigger-condition-modal.component.ts | 6 +++++- 8 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts index 611811adc..b934a4dcc 100644 --- a/src/app/triggers/trigger-state.service.ts +++ b/src/app/triggers/trigger-state.service.ts @@ -304,7 +304,12 @@ export class TriggerStateService { title: i18n('TRIGGERS.REMOVE_INSTANCE_TITLE', { type }), content: i18n('TRIGGERS.REMOVE_INSTANCE_MSG', { type, - name: escapeHtml(instance.name), + // Instances have no name, so match the id the list shows + name: escapeHtml( + instance.name || + instance.zone_id || + instance.control_system_id, + ), }), icon: { type: 'icon', content: 'delete' }, }, diff --git a/src/app/ui/custom-fields/date-calendar.component.ts b/src/app/ui/custom-fields/date-calendar.component.ts index d1c6d7e18..06694943c 100644 --- a/src/app/ui/custom-fields/date-calendar.component.ts +++ b/src/app/ui/custom-fields/date-calendar.component.ts @@ -41,6 +41,7 @@ interface DateItem {