mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(ui): add independent live panel state
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user