From 65fa07d208df6b89fd80678d1c826726e9ab3ca4 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 27 Apr 2026 21:08:17 +0100 Subject: [PATCH] refactor(mvc): Phase 1 - Base Controllers and Display Types View --- agent_docs/06_MVC_REFACTOR_PLAN.md | 40 +++ eink/frontend/package-lock.json | 60 +++-- .../views/display-types-view.test.ts | 59 +++-- .../components/views/display-types-view.ts | 231 +++++------------- .../src/controllers/BaseViewController.ts | 59 +++++ .../controllers/DisplayTypesViewController.ts | 192 +++++++++++++++ 6 files changed, 421 insertions(+), 220 deletions(-) create mode 100644 agent_docs/06_MVC_REFACTOR_PLAN.md create mode 100644 eink/frontend/src/controllers/BaseViewController.ts create mode 100644 eink/frontend/src/controllers/DisplayTypesViewController.ts diff --git a/agent_docs/06_MVC_REFACTOR_PLAN.md b/agent_docs/06_MVC_REFACTOR_PLAN.md new file mode 100644 index 00000000..9b2f442a --- /dev/null +++ b/agent_docs/06_MVC_REFACTOR_PLAN.md @@ -0,0 +1,40 @@ +# Frontend MVC Refactor Plan + +This document details the multi-phase strategy to migrate the frontend application from Lit's built-in component state management to an MVC (Model-View-Controller) architecture based around `ReactiveController`. + +Due to the scale of the original refactor (~4,500 lines across 42 files) and complex integration issues like the Lit component update lifecycle looping when handling global `notifyDirty` events, the migration will be completed across 4 separate, reviewable Pull Requests. + +## Overview of the Architecture + +* **HaStateController:** Central state orchestrator connecting directly to the Home Assistant websocket API. +* **BaseViewController:** An abstract `ReactiveController` instance bound to UI views. Handles interactions explicitly and acts as the source of truth for the local view representation. View controllers communicate selectively back to the application shell via standard custom events. +* **Component Presentation:** Native `LitElement` classes stripped of all significant logical logic, simply rendering whatever state attributes the controllers expose. + +## Phase 1: Core Framework & Display Types (Current) +* **Goal:** Prove the foundational architecture in isolation. +* **Implementation:** + 1. Add `BaseViewController`. + 2. Modify `BaseResourceView` to implement decoupled communication handlers that don't bind blindly to `updated()` lifecycles. + 3. Migrate `DisplayTypesView` entirely to utilize `DisplayTypesViewController`. + 4. Ensure `app-root` receives generic framework events securely without conflicting with legacy components. + +## Phase 2: Dialogs & Static Assets +* **Goal:** Migrate complex application dialogues and the decoupled Image Library pane. +* **Implementation:** + 1. Migrate the global `ConfirmDialog`, `ImageDialog`, and `LayoutSettingsDialog`. + 2. Implement `ImagesViewController` and migrate `ImagesView`. + +## Phase 3: Layouts Infrastructure +* **Goal:** Migrate the core Layout builder canvas. +* **Implementation:** + 1. Port `LayoutEditorController`. + 2. Port `LayoutsViewController`. + 3. Convert both `LayoutEditor` and `LayoutsView` to purely presentational. + +## Phase 4: Scenes Infrastructure & Shell +* **Goal:** Wrap up the most complex dependency tree routing. +* **Implementation:** + 1. Port the `ScenesViewController` and `ScenesView`. + 2. Port `SceneItemSettingsDialog` and `SceneItemSettingsController`. + 3. Migrate the `AppToolbar` and `SideBar` components to use localized controllers. + 4. Finish cleaning up legacy routines inside `app-root`. diff --git a/eink/frontend/package-lock.json b/eink/frontend/package-lock.json index 68168288..07a071c2 100644 --- a/eink/frontend/package-lock.json +++ b/eink/frontend/package-lock.json @@ -469,6 +469,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -486,6 +489,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -503,6 +509,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -520,6 +529,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -537,6 +549,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -554,6 +569,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1005,9 +1023,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -1113,28 +1131,28 @@ } }, "node_modules/jsdom": { - "version": "29.0.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", - "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", + "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.5", - "@asamuzakjp/dom-selector": "^7.0.6", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -1296,6 +1314,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1317,6 +1338,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1338,6 +1362,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1359,6 +1386,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1553,9 +1583,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { diff --git a/eink/frontend/src/components/views/display-types-view.test.ts b/eink/frontend/src/components/views/display-types-view.test.ts index af042f5a..4516ac7d 100644 --- a/eink/frontend/src/components/views/display-types-view.test.ts +++ b/eink/frontend/src/components/views/display-types-view.test.ts @@ -36,6 +36,11 @@ describe('DisplayTypesView', () => { beforeEach(async () => { element = document.createElement('display-types-view') as DisplayTypesView; + element.state = { + displayTypes: mockDisplayTypes, + refresh: vi.fn(), + showMessage: vi.fn(), + } as any; element.displayTypes = mockDisplayTypes as any; // Explicitly set to the first one to avoid auto-pick logic interference for tests that don't want it element.selectedId = 'dt1'; @@ -48,48 +53,47 @@ describe('DisplayTypesView', () => { }); it('should initialise with the first display type selected', () => { - expect(element.displayType?.id).toBe('dt1'); - expect(element.isNew).toBe(false); + expect(element.controller.activeType?.id).toBe('dt1'); + expect(element.controller.isAdding).toBe(false); }); it('should be blank when nothing is selected and not adding', async () => { - element.selectedId = null; - element.isAdding = false; + element.controller.selectType(null); + element.controller.isAdding = false; await element.updateComplete; - expect(element.displayType).toBeUndefined(); + expect(element.controller.activeType).toBeNull(); - const toolbarTitle = element.shadowRoot?.querySelector('.toolbar-title'); - expect(toolbarTitle?.textContent?.trim()).toBe('Display Types'); + const emptyView = element.shadowRoot?.querySelector('empty-view'); expect(emptyView).toBeTruthy(); }); - it('should dispatch select-display-type when another display type is clicked in sidebar', async () => { - const selectSpy = vi.fn(); - element.addEventListener('select-display-type', selectSpy); + it('should call controller.selectType when another display type is clicked in sidebar', async () => { + const selectSpy = vi.spyOn(element.controller, 'selectType'); const sidebarList = element.shadowRoot?.querySelector('sidebar-list'); const items = sidebarList?.shadowRoot?.querySelectorAll('.sidebar-item'); // Index 1 is the 2nd display type (Index 0 is the 1st) (items?.[1] as HTMLElement).click(); - expect(selectSpy).toHaveBeenCalledWith(expect.objectContaining({ - detail: { id: 'dt2' } - })); + expect(selectSpy).toHaveBeenCalledWith('dt2'); }); - it('should dispatch prepare-new-display-type when addNew is called', async () => { - const prepareSpy = vi.fn(); - element.addEventListener('prepare-new-display-type', prepareSpy); + it('should call controller.addNew when addNew is called', async () => { + const spy = vi.spyOn(element.controller, 'addNew'); element.addNew(); - expect(prepareSpy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); it('should detect dirty state when fields are modified', async () => { + // Select the first item so there is a form to edit + element.controller.selectType('dt1'); + await element.updateComplete; + expect(element.isDirty).toBe(false); const nameInput = element.shadowRoot?.querySelector('input[type="text"]') as HTMLInputElement; @@ -100,27 +104,22 @@ describe('DisplayTypesView', () => { expect(element.isDirty).toBe(true); }); - it('should emit save event when form is submitted', async () => { - const saveSpy = vi.fn(); - element.addEventListener('save', saveSpy); + it('should call controller.save when form is submitted', async () => { + const saveSpy = vi.spyOn(element.controller, 'save').mockImplementation(async () => {}); - // Fill in a name for a new device - element.selectedId = null; - element.isAdding = true; + // Call addNew to setup correct temporary state + element.addNew(); await element.updateComplete; const nameInput = element.shadowRoot?.querySelector('input[type="text"]') as HTMLInputElement; nameInput.value = 'New Device'; nameInput.dispatchEvent(new Event('input', { bubbles: true })); - // Manually trigger submit - (element as any)._handleSubmit(new Event('submit')); + // Manually trigger submit on the form + const form = element.shadowRoot?.querySelector('form'); + form?.dispatchEvent(new SubmitEvent('submit', { cancelable: true })); - expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ - detail: expect.objectContaining({ - displayType: expect.objectContaining({ name: 'New Device' }) - }) - })); + expect(saveSpy).toHaveBeenCalled(); }); it('should calculate correct dimensions for summary table', async () => { diff --git a/eink/frontend/src/components/views/display-types-view.ts b/eink/frontend/src/components/views/display-types-view.ts index e2dfa586..37e7cb5a 100644 --- a/eink/frontend/src/components/views/display-types-view.ts +++ b/eink/frontend/src/components/views/display-types-view.ts @@ -1,5 +1,5 @@ import { html, css } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import type { DisplayType } from '../../services/HaApiClient'; import { commonStyles } from '../../styles/common-styles'; @@ -8,6 +8,8 @@ import '../shared/empty-view'; import '../shared/hardware-preview'; import '../shared/section-layout'; import '../shared/sidebar-list'; +import { DisplayTypesViewController } from '../../controllers/DisplayTypesViewController'; +import { HaStateController } from '../../controllers/HaStateController'; import '../layout/yaml-editor'; /** @@ -196,22 +198,12 @@ export class DisplayTypesView extends BaseResourceView { ` ]; - @property({ type: Object }) displayType?: DisplayType; + @property({ type: Object }) state!: HaStateController; @property({ type: Array }) displayTypes: DisplayType[] = []; @property({ type: String }) selectedId: string | null = null; - @property({ type: Boolean }) isNew = true; @property({ type: String }) viewMode: 'graphical' | 'yaml' = 'graphical'; - @property({ type: Boolean }) isAdding = false; - @state() private _isDirtyState = false; - - get isDirty() { - return this._isDirtyState; - } - - get canDelete() { - return !this.isNew && !!this.displayType; - } + public controller = new DisplayTypesViewController(this); private _PRESETS = [ { name: 'White', colour: '#ffffff' }, @@ -220,147 +212,38 @@ export class DisplayTypesView extends BaseResourceView { { name: 'Silver', colour: '#c0c0c0' } ]; - private _getDefaultDisplayType(): DisplayType { - return { - id: '', - name: '', - width_mm: 0, - height_mm: 0, - panel_width_mm: 0, - panel_height_mm: 0, - width_px: 0, - height_px: 0, - colour_type: 'MONO', - frame: { border_width_mm: 0, colour: '#000000' }, - mat: { colour: '#ffffff' } - }; - } - - protected willUpdate(changedProperties: Map) { - if (changedProperties.has('selectedId') || changedProperties.has('displayTypes') || changedProperties.has('isAdding')) { - this._syncSelection(); - } - if (changedProperties.has('displayType') || changedProperties.has('displayTypes') || changedProperties.has('isNew')) { - this._updateDirtyState(); - this.notifyCanDelete(!this.isNew && !!this.displayType); + protected updated(changedProperties: Map) { + super.updated(changedProperties); + if (changedProperties.has('state') || changedProperties.has('displayTypes')) { + this.controller.resetBaseline(); } - } - - private _syncSelection() { - const found = this.selectedId ? this.displayTypes.find(dt => dt.id === this.selectedId) : null; - - if (found) { - if (this.displayType?.id !== found.id || this.isNew) { - this.displayType = JSON.parse(JSON.stringify(found)); - this.isNew = false; - } - } else { - if (this.isAdding) { - if (!this.isNew || !this.displayType || this.displayType.id !== '') { - this.displayType = this._getDefaultDisplayType(); - this.isNew = true; - } - } else { - this.displayType = undefined; - this.isNew = true; - } + if (changedProperties.has('selectedId') && this.selectedId !== undefined) { + this.controller.selectType(this.selectedId); } } - private _isDirty(): boolean { - if (!this.displayType) return false; - if (this.isNew) { - const defaultValue = this._getDefaultDisplayType(); - return JSON.stringify(this.displayType) !== JSON.stringify(defaultValue); - } - const original = this.displayTypes.find(dt => dt.id === this.displayType?.id); - if (!original) return true; - return JSON.stringify(this.displayType) !== JSON.stringify(original); + get isDirty() { + return this.controller.isDirty; } - private _updateDirtyState() { - const dirty = this._isDirty(); - if (this._isDirtyState !== dirty) { - this._isDirtyState = dirty; - this.notifyDirty(dirty); - } + get canDelete() { + return this.controller.canDelete; } public save() { - const form = this.shadowRoot?.querySelector('form'); - if (form?.checkValidity()) { - this._handleSubmit(new Event('submit')); - } else { - form?.reportValidity(); - } + this.controller.save(); } public discard() { - this.displayType = undefined; - this._syncSelection(); - this._updateDirtyState(); - this.requestUpdate(); + this.controller.discard(); } public addNew() { - this.dispatchEvent(new CustomEvent('prepare-new-display-type', { bubbles: true, composed: true })); - } - - private async _handleSelect(id: string | null) { - if (id === null && this.isAdding) return; - if (id !== null && id === this.selectedId && !this.isAdding) return; - - const performSwitch = () => { - this.dispatchEvent(new CustomEvent('select-display-type', { - detail: { id }, - bubbles: true, - composed: true - })); - }; - - if (this._isDirty()) { - this._requestConfirmation( - { - title: 'Unsaved Changes', - message: `You have unsaved changes to "${this.displayType?.name}". What would you like to do?`, - buttons: [ - { text: 'Save', value: 'save', type: 'primary' }, - { text: 'Discard', value: 'discard', type: 'danger' }, - { text: 'Cancel', value: 'cancel', type: 'secondary' } - ] - }, - async (choice: string) => { - if (choice === 'save') { - const form = this.shadowRoot?.querySelector('form'); - if (form?.checkValidity()) { - this._handleSubmit(new Event('submit')); - performSwitch(); - } else { - form?.reportValidity(); - } - } else if (choice === 'discard') { - performSwitch(); - } - } - ); - } else { - performSwitch(); - } + this.controller.addNew(); } public requestDelete() { - if (!this.displayType || this.isNew) return; - this.dispatchEvent(new CustomEvent('delete-display-type', { detail: this.displayType })); - } - - private _handleSubmit(e: Event) { - e.preventDefault(); - if (!this.displayType) return; - - this.dispatchEvent(new CustomEvent('save', { detail: { displayType: this.displayType } })); - this.isNew = false; - this.requestUpdate(); - this._updateDirtyState(); + this.controller.requestDelete(); } private _renderColourPicker(label: string, value: string, onUpdate: (colour: string) => void) { @@ -373,7 +256,7 @@ export class DisplayTypesView extends BaseResourceView { class="swatch ${value.toLowerCase() === p.colour.toLowerCase() ? 'selected' : ''}" style="background: ${p.colour}" title="${p.name}" - @click="${() => onUpdate(p.colour)}" + @click="${() => { onUpdate(p.colour); this.requestUpdate(); }}" > `)} @@ -381,7 +264,7 @@ export class DisplayTypesView extends BaseResourceView {
${value}
@@ -394,11 +277,13 @@ export class DisplayTypesView extends BaseResourceView { } render() { - const frameW = this.displayType?.width_mm || 0; - const frameH = this.displayType?.height_mm || 0; - const border = this.displayType?.frame?.border_width_mm || 0; - const panelW = this.displayType?.panel_width_mm || 0; - const panelH = this.displayType?.panel_height_mm || 0; + if (!this.controller.activeType) return html``; + + const frameW = this.controller.activeType.width_mm || 0; + const frameH = this.controller.activeType.height_mm || 0; + const border = this.controller.activeType.frame?.border_width_mm || 0; + const panelW = this.controller.activeType.panel_width_mm || 0; + const panelH = this.controller.activeType.panel_height_mm || 0; const matW = frameW - (2 * border); const matH = frameH - (2 * border); @@ -410,7 +295,7 @@ export class DisplayTypesView extends BaseResourceView { ? maxPreviewDim / Math.max(frameW, frameH) : 1; - const listItems = this.displayTypes.map(dt => ({ + const listItems = this.controller.displayTypes.map(dt => ({ id: dt.id, name: dt.name, iconHtml: html` @@ -434,19 +319,19 @@ export class DisplayTypesView extends BaseResourceView {
- ${this.isAdding ? 'Create New Display Type' : (this.displayType && !this.isNew ? this.displayType.name : 'Display Types')} + ${this.controller.isAdding ? 'Create New Display Type' : (this.controller.activeType ? this.controller.activeType.name : 'Display Types')}
-
- ${!this.displayType ? html` +
+ ${!this.controller.activeType ? html` ` : html` ${this.viewMode === 'graphical' ? html` -
+
@@ -470,30 +355,30 @@ export class DisplayTypesView extends BaseResourceView {
- +
- +
- +
- +
- +
- @@ -507,19 +392,19 @@ export class DisplayTypesView extends BaseResourceView {
- +
- +
Aesthetics
- ${this._renderColourPicker('Frame Colour', this.displayType.frame.colour, (c) => { this.displayType!.frame.colour = c; this.requestUpdate(); })} - ${this._renderColourPicker('Mat Colour', this.displayType.mat.colour, (c) => { this.displayType!.mat.colour = c; this.requestUpdate(); })} + ${this._renderColourPicker('Frame Colour', this.controller.activeType.frame.colour, (c) => this.controller.updateActiveType({ frame: { ...this.controller.activeType!.frame, colour: c } }))} + ${this._renderColourPicker('Mat Colour', this.controller.activeType.mat.colour, (c) => this.controller.updateActiveType({ mat: { ...this.controller.activeType!.mat, colour: c } }))}
@@ -528,13 +413,9 @@ export class DisplayTypesView extends BaseResourceView { ` : html` `} @@ -546,13 +427,13 @@ export class DisplayTypesView extends BaseResourceView {
diff --git a/eink/frontend/src/controllers/BaseViewController.ts b/eink/frontend/src/controllers/BaseViewController.ts new file mode 100644 index 00000000..ce9e8d37 --- /dev/null +++ b/eink/frontend/src/controllers/BaseViewController.ts @@ -0,0 +1,59 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { HaStateController } from './HaStateController'; + +/** + * A base class for view and dialog controllers that provides access to the global + * HaStateController and common utilities like dirty checking and message reporting. + */ +export abstract class BaseViewController implements ReactiveController { + constructor(protected host: ReactiveControllerHost & { state?: HaStateController } & HTMLElement) { + this.host.addController(this); + } + + hostConnected() { + // Subclasses can override + } + + hostDisconnected() { + // Subclasses can override + } + + /** + * Access the shared application state. + */ + get state(): HaStateController { + if (!this.host.state) { + throw new Error('HaStateController is not present on the host'); + } + return this.host.state; + } + + /** + * Reports a message via the global state. + */ + protected showMessage(text: string, type: 'info' | 'success' | 'error' = 'info') { + this.state.showMessage(text, type); + } + + /** + * Dispatches a dirty state change event to notify the shell. + */ + protected notifyDirty(isDirty: boolean) { + this.host.dispatchEvent(new CustomEvent('dirty-state-change', { + detail: { isDirty }, + bubbles: true, + composed: true + })); + } + + /** + * Dispatches a can-delete state change event to notify the shell. + */ + protected notifyCanDelete(canDelete: boolean) { + this.host.dispatchEvent(new CustomEvent('can-delete-change', { + detail: { canDelete }, + bubbles: true, + composed: true + })); + } +} diff --git a/eink/frontend/src/controllers/DisplayTypesViewController.ts b/eink/frontend/src/controllers/DisplayTypesViewController.ts new file mode 100644 index 00000000..c53d21f9 --- /dev/null +++ b/eink/frontend/src/controllers/DisplayTypesViewController.ts @@ -0,0 +1,192 @@ +import { DisplayType, api } from '../services/HaApiClient'; +import { BaseViewController } from './BaseViewController'; + +/** + * Controller for the Display Types View. + * Manages display type selection, editing, and dirty state. + */ +export class DisplayTypesViewController extends BaseViewController { + public editingItemId: string | null = null; + public isAdding = false; + private _originalTypesJson: string | null = null; + private _localTypes: DisplayType[] = []; + + get displayTypes(): DisplayType[] { + return this._localTypes.length > 0 ? this._localTypes : this.state.displayTypes; + } + + get isDirty() { + return JSON.stringify(this.displayTypes) !== this._originalTypesJson; + } + + get activeType(): DisplayType | null { + return this.displayTypes.find(t => t.id === this.editingItemId) || null; + } + + get canDelete() { + return !!this.activeType && !!this.activeType.id && !this.isAdding; + } + + public hostConnected() { + this.resetBaseline(); + } + + public resetBaseline() { + this._localTypes = JSON.parse(JSON.stringify(this.state.displayTypes)); + this._originalTypesJson = JSON.stringify(this._localTypes); + this.notifyDirty(false); + this.notifyCanDelete(this.canDelete); + } + + public selectType(id: string | null) { + if (this.editingItemId === id) return; + + const performSelect = () => { + this.editingItemId = id; + this.isAdding = false; + this.notifyCanDelete(this.canDelete); + this.host.requestUpdate(); + }; + + if (this.isDirty) { + this.host.dispatchEvent(new CustomEvent('request-confirmation', { + detail: { + config: { + title: 'Unsaved Changes', + message: 'You have unsaved changes to display types. What would you like to do?', + buttons: [ + { text: 'Save', value: 'save', type: 'primary' }, + { text: 'Discard', value: 'discard', type: 'danger' }, + { text: 'Cancel', value: 'cancel', type: 'secondary' } + ] + }, + callback: async (choice: string) => { + if (choice === 'save') { + await this.save(); + performSelect(); + } else if (choice === 'discard') { + this.discard(); + performSelect(); + } + } + }, + bubbles: true, + composed: true + })); + } else { + performSelect(); + } + } + + public async save() { + try { + // Save all modified types + const savedIds = new Set(); + const originalTypes = JSON.parse(this._originalTypesJson || '[]'); + + for (const type of this._localTypes) { + const original = originalTypes.find((t: any) => t.id === type.id); + if (JSON.stringify(type) !== JSON.stringify(original)) { + if (type.id.startsWith('new_')) { + const { id: _, ...rest } = type; + const result = await api.createItem('display_type', rest); + savedIds.add(result.id); + } else { + await api.updateItem('display_type', type.id, type); + savedIds.add(type.id); + } + } + } + + await this.state.refresh(); + this.resetBaseline(); + if (this.editingItemId?.startsWith('new_')) { + // Auto-select the first newly saved one if we were adding + this.editingItemId = this.state.displayTypes[this.state.displayTypes.length -1].id; + } + this.notifyCanDelete(this.canDelete); + this.showMessage(`Display type "${this.activeType?.name || ''}" saved!`, 'success'); + } catch (err: any) { + this.showMessage(err.message || 'Failed to save display types', 'error'); + } + } + + public discard() { + this.resetBaseline(); + this.host.requestUpdate(); + } + + public addNew() { + const newId = `new_${Math.random().toString(36).substr(2, 9)}`; + const newType: DisplayType = { + id: newId, + name: 'New Display Type', + width_mm: 200, + height_mm: 150, + width_px: 800, + height_px: 600, + panel_width_mm: 190, + panel_height_mm: 140, + colour_type: 'BW', + frame: { colour: '#000000', border_width_mm: 5 }, + mat: { colour: '#ffffff' } + }; + + this._localTypes = [...this._localTypes, newType]; + this.editingItemId = newId; + this.isAdding = true; + this.notifyDirty(true); + this.notifyCanDelete(this.canDelete); + this.host.requestUpdate(); + } + + public updateActiveType(updates: Partial) { + if (!this.editingItemId) return; + this._localTypes = this._localTypes.map(t => + t.id === this.editingItemId ? { ...t, ...updates } : t + ); + this.notifyDirty(this.isDirty); + this.host.requestUpdate(); + } + + public async requestDelete() { + if (!this.activeType || this.isAdding) { + if (this.isAdding) { + this._localTypes = this._localTypes.filter(t => t.id !== this.editingItemId); + this.editingItemId = null; + this.isAdding = false; + this.notifyDirty(this.isDirty); + this.notifyCanDelete(this.canDelete); + this.host.requestUpdate(); + } + return; + } + + this.host.dispatchEvent(new CustomEvent('request-confirmation', { + detail: { + config: { + title: 'Delete Display Type?', + message: `Are you sure you want to delete "${this.activeType.name}"? This will fail if it's used in any layouts.`, + confirmText: 'Delete', + type: 'danger' + }, + callback: async (confirmed: boolean) => { + if (confirmed) { + try { + await api.deleteItem('display_type', this.activeType!.id); + await this.state.refresh(); + this.resetBaseline(); + this.editingItemId = null; + this.notifyCanDelete(this.canDelete); + this.showMessage('Display type deleted', 'success'); + } catch (err: any) { + this.showMessage(err.message || 'Failed to delete display type', 'error'); + } + } + } + }, + bubbles: true, + composed: true + })); + } +}