feat(ui): add independent live panel state

This commit is contained in:
4gray committed 2026-07-31 08:43:37 +02:00
1 parent 85a56115c8
commit 3a0d507670
5 files changed
+315 -11

No files matched your search

@@ -68,7 +68,7 @@ SCSS, ngx-translate.
- Delete:
`libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.ts`
- [ ] **Step 1: Write failing migration and service tests**
- [x] **Step 1: Write failing migration and service tests**
Cover missing/invalid storage, valid legacy seeding, partial new-key migration,
new-key precedence, idempotence, independent persistence, local action clearing
@@ -94,7 +94,7 @@ expect(service.masterSuppressed()).toBe(false);
expect(service.channelsIntent()).toBe('expanded');
```
- [ ] **Step 2: Run the target and verify RED**
- [x] **Step 2: Run the target and verify RED**
Run:
@@ -105,7 +105,7 @@ pnpm nx test portal-shared-data-access --runInBand
Expected: FAIL because `LiveLayoutPanelStateService` and its storage constants
do not exist.
- [ ] **Step 3: Implement the minimal signal service**
- [x] **Step 3: Implement the minimal signal service**
Use a const-backed panel kind, readonly public signals, and explicit commands:
@@ -151,13 +151,13 @@ Migration reads valid new keys first, falls back per missing key to valid
`live-sidebar-state`, defaults to expanded, writes both new keys, and never
writes the legacy key.
- [ ] **Step 4: Replace exports and remove the legacy service**
- [x] **Step 4: Replace exports and stage legacy service removal**
Export the new service from `@iptvnator/portal/shared/data-access`. Remove only
the injectable legacy service export/file; retain
`restoreLiveSidebarState()` for migration compatibility.
- [ ] **Step 5: Run the target and verify GREEN**
- [x] **Step 5: Run the target and verify GREEN**
Run:
@@ -169,7 +169,7 @@ pnpm nx lint portal-shared-util
Expected: all commands exit 0.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add libs/portal/shared/data-access libs/portal/shared/util
@@ -1,2 +1,3 @@
export * from './lib/collection';
export * from './lib/live-layout-panel-state.service';
export * from './lib/multi-source';
@@ -0,0 +1,172 @@
import { TestBed } from '@angular/core/testing';
import { LIVE_SIDEBAR_STATE_STORAGE_KEY } from '@iptvnator/portal/shared/util';
import {
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
LiveLayoutPanelStateService,
} from './live-layout-panel-state.service';
describe('LiveLayoutPanelStateService', () => {
afterEach(() => {
TestBed.resetTestingModule();
localStorage.clear();
});
it('defaults both panel intents to expanded and persists the migration', () => {
const service = createService();
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
expect(
localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
});
it('seeds missing panel intents from a valid legacy sidebar state', () => {
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed');
const service = createService();
expect(service.groupsIntent()).toBe('collapsed');
expect(service.channelsIntent()).toBe('collapsed');
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
'collapsed'
);
});
it('keeps valid new keys and migrates only a missing or invalid sibling', () => {
localStorage.setItem(
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
'expanded'
);
localStorage.setItem(
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
'hidden'
);
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed');
const service = createService();
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('collapsed');
expect(
localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY)
).toBe('collapsed');
});
it('does not let a changed legacy value overwrite migrated new keys', () => {
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed');
createService();
TestBed.resetTestingModule();
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'expanded');
const service = createService();
expect(service.groupsIntent()).toBe('collapsed');
expect(service.channelsIntent()).toBe('collapsed');
});
it('persists Groups and Channels intents independently', () => {
const service = createService();
service.hidePanel('groups');
expect(service.groupsIntent()).toBe('collapsed');
expect(service.channelsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('collapsed');
expect(
localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
service.hidePanel('channels');
service.showPanel('groups');
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('collapsed');
});
it('resolves effective visibility without persisting applicability or responsive suppression', () => {
const service = createService();
expect(
service.isPanelExpanded('groups', {
applicable: true,
})
).toBe(true);
expect(
service.isPanelExpanded('groups', {
applicable: false,
})
).toBe(false);
expect(
service.isPanelExpanded('groups', {
applicable: true,
responsiveSuppressed: true,
})
).toBe(false);
expect(service.groupsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
});
it('temporarily suppresses applicable visible panels and restores their persisted intents', () => {
const service = createService();
service.hidePanel('channels');
service.toggleMasterSuppression(['groups', 'channels']);
expect(service.masterSuppressed()).toBe(true);
expect(
service.isPanelExpanded('groups', { applicable: true })
).toBe(false);
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('collapsed');
service.toggleMasterSuppression(['groups', 'channels']);
expect(service.masterSuppressed()).toBe(false);
expect(
service.isPanelExpanded('groups', { applicable: true })
).toBe(true);
expect(
service.isPanelExpanded('channels', { applicable: true })
).toBe(false);
});
it('exits master suppression before applying a panel-local action', () => {
const service = createService();
service.toggleMasterSuppression(['groups', 'channels']);
service.showPanel('channels');
expect(service.masterSuppressed()).toBe(false);
expect(service.channelsIntent()).toBe('expanded');
service.toggleMasterSuppression(['groups', 'channels']);
service.hidePanel('groups');
expect(service.masterSuppressed()).toBe(false);
expect(service.groupsIntent()).toBe('collapsed');
});
it('leaves master suppression off when no applicable intent is visible', () => {
const service = createService();
service.hidePanel('groups');
service.hidePanel('channels');
service.toggleMasterSuppression(['groups', 'channels']);
expect(service.masterSuppressed()).toBe(false);
});
});
function createService(): LiveLayoutPanelStateService {
TestBed.configureTestingModule({});
return TestBed.inject(LiveLayoutPanelStateService);
}
@@ -0,0 +1,133 @@
import { Injectable, signal } from '@angular/core';
import {
DEFAULT_LIVE_SIDEBAR_STATE,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
LiveSidebarState,
isLiveSidebarState,
} from '@iptvnator/portal/shared/util';
export const LIVE_LAYOUT_PANEL = {
GROUPS: 'groups',
CHANNELS: 'channels',
} as const;
export type LiveLayoutPanel =
(typeof LIVE_LAYOUT_PANEL)[keyof typeof LIVE_LAYOUT_PANEL];
export type LivePanelState = LiveSidebarState;
export const LIVE_GROUPS_PANEL_STATE_STORAGE_KEY =
'live-groups-panel-state';
export const LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY =
'live-channels-panel-state';
export interface LivePanelEffectiveContext {
readonly applicable: boolean;
readonly responsiveSuppressed?: boolean;
}
interface LiveLeftPanelIntents {
readonly groups: LivePanelState;
readonly channels: LivePanelState;
}
@Injectable({ providedIn: 'root' })
export class LiveLayoutPanelStateService {
private readonly restoredIntents = restoreLiveLeftPanelIntents();
private readonly _groupsIntent = signal(this.restoredIntents.groups);
private readonly _channelsIntent = signal(this.restoredIntents.channels);
private readonly _masterSuppressed = signal(false);
readonly groupsIntent = this._groupsIntent.asReadonly();
readonly channelsIntent = this._channelsIntent.asReadonly();
readonly masterSuppressed = this._masterSuppressed.asReadonly();
isPanelExpanded(
panel: LiveLayoutPanel,
context: LivePanelEffectiveContext
): boolean {
return (
context.applicable &&
!context.responsiveSuppressed &&
!this._masterSuppressed() &&
this.intentFor(panel)() === 'expanded'
);
}
hidePanel(panel: LiveLayoutPanel): void {
this.setPanelIntent(panel, 'collapsed');
}
showPanel(panel: LiveLayoutPanel): void {
this.setPanelIntent(panel, 'expanded');
}
toggleMasterSuppression(
applicablePanels: readonly LiveLayoutPanel[]
): void {
if (this._masterSuppressed()) {
this._masterSuppressed.set(false);
return;
}
const hasVisiblePanel = applicablePanels.some(
(panel) => this.intentFor(panel)() === 'expanded'
);
this._masterSuppressed.set(hasVisiblePanel);
}
private setPanelIntent(
panel: LiveLayoutPanel,
state: LivePanelState
): void {
this._masterSuppressed.set(false);
const target =
panel === LIVE_LAYOUT_PANEL.GROUPS
? this._groupsIntent
: this._channelsIntent;
target.set(state);
localStorage.setItem(storageKeyFor(panel), state);
}
private intentFor(panel: LiveLayoutPanel) {
return panel === LIVE_LAYOUT_PANEL.GROUPS
? this._groupsIntent
: this._channelsIntent;
}
}
function restoreLiveLeftPanelIntents(): LiveLeftPanelIntents {
const legacyValue = localStorage.getItem(
LIVE_SIDEBAR_STATE_STORAGE_KEY
);
const legacyFallback = isLiveSidebarState(legacyValue)
? legacyValue
: DEFAULT_LIVE_SIDEBAR_STATE;
const groups = restorePanelIntent(
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
legacyFallback
);
const channels = restorePanelIntent(
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
legacyFallback
);
localStorage.setItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, groups);
localStorage.setItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, channels);
return { groups, channels };
}
function restorePanelIntent(
storageKey: string,
fallback: LivePanelState
): LivePanelState {
const storedValue = localStorage.getItem(storageKey);
return isLiveSidebarState(storedValue) ? storedValue : fallback;
}
function storageKeyFor(panel: LiveLayoutPanel): string {
return panel === LIVE_LAYOUT_PANEL.GROUPS
? LIVE_GROUPS_PANEL_STATE_STORAGE_KEY
: LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY;
}
@@ -6,11 +6,9 @@ import {
} from './live-sidebar-state';
/**
* Shared collapse state for the live-TV sidebar across the workspace shell
* categories rail (Xtream/Stalker), the inline channels rail, and the
* unified-collection live tab. A single signal keeps all surfaces in sync
* within a session; localStorage persistence is delegated to the existing
* `live-sidebar-state` helpers so the storage key stays unchanged.
* @deprecated Migrate consumers to `LiveLayoutPanelStateService` from
* `@iptvnator/portal/shared/data-access`. This compatibility service remains
* until every existing live layout has moved in the current change.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {