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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions agent_docs/06_MVC_REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
@@ -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`.
60 changes: 45 additions & 15 deletions eink/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 29 additions & 30 deletions eink/frontend/src/components/views/display-types-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down
Loading
Loading