fix(parental-lock): focus, tokens and phone width for lock UI (#1761)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 07:03:29 +02:00
1 parent 03dfafd68b
commit dc3f829807
20 files changed
+826 -121

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: settings
---
Parental lock screens are easier to use: keyboard focus is visible on the "N locked" rows and in the Stalker lock list, locked categories stand out in the management dialogs, a wrong PIN shows in red, and the lock dialogs fit on a phone. In the browser version the category dialog no longer shows hide/show controls that do nothing there.
+412
View File
@@ -0,0 +1,412 @@
import type { Locator, Page } from '@playwright/test';
import { join } from 'path';
import { expect, test } from './fixtures';
import { pressTab, surfaceContrast } from './e2e-helpers';
import {
addStalkerPortal,
addXtreamPortal,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* Parental lock UI on app tokens (PWA).
*
* The lock rows, dialogs and PIN error once used Material system colours the
* theme never declares, so every one of them resolved to nothing: no focus
* ring after the outline reset, a dashed border and list border with no
* colour, a PIN error in body text, and dialogs 440–480px wide on a 375px
* phone. These tests pin the visible result in both themes:
*
* 1. Tabbing to each "N locked" row and each Stalker lock row shows a
* 2px ring with >= 3:1 contrast against the row and its surface.
* 2. The rail rows' dashed border and the lock list's border are painted.
* 3. The PIN error is a red with >= 4.5:1 contrast on the dialog.
* 4. The lock dialogs fit a 375px viewport without sideways overflow.
* 5. In the PWA the Xtream dialog shows lock controls only — hide/show is
* Electron-only — and its clear-search button has a name.
*
* Tag: @parental — run only this spec with:
* pnpm nx run web-e2e:e2e-ci--src/parental-lock-ui.e2e.ts
*/
const M3U_FIXTURE = join(__dirname, 'fixtures/test.m3u');
const PIN = '2468';
const PHONE = { width: 375, height: 812 };
const THEMES = ['light', 'dark'] as const;
async function setTheme(page: Page, theme: (typeof THEMES)[number]) {
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
}
async function enableParentalLock(page: Page): Promise<void> {
await page.locator('a[href$="/workspace/settings"]').click();
await page.waitForURL(/\/workspace\/settings\/general$/);
await page.locator('[data-test-id="settings-section-parental"]').click();
await page.waitForURL(/\/workspace\/settings\/parental$/);
const toggle = page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
await toggle.click();
await page.locator('[data-test-id="parental-lock-pin"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-confirm"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
}
/** Answers the unlock prompt that a PIN-gated action opens. */
async function enterPin(page: Page, pin = PIN): Promise<void> {
const input = page.locator('[data-test-id="parental-lock-pin"]');
await expect(input).toBeVisible();
await input.fill(pin);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
}
/**
* The dialog opened for `component`, once it holds focus: MatDialog moves
* focus to its first tabbable element only after the open animation, which
* would otherwise pull focus away from a row the test has just tabbed to.
*/
async function openedDialog(page: Page, component: string): Promise<Locator> {
const dialog = page.locator('mat-dialog-container', {
has: page.locator(component),
});
await expect
.poll(() =>
dialog.evaluate((el) => el.contains(document.activeElement))
)
.toBe(true);
return dialog;
}
/**
* WCAG contrast of one colour property against what is painted behind the
* element: white, each ancestor's background and, with `ownFill`, the
* element's own background (the approach of `surfaceContrast`).
*/
async function contrastBehind(
locator: Locator,
property: 'color' | 'outlineColor',
ownFill: boolean
): Promise<number> {
return locator.evaluate(
(element, [property, ownFill]) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is required');
const paint = (color: string) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
};
const luminance = () =>
Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0
);
const layers: Element[] = ownFill ? [element] : [];
for (let e = element.parentElement; e; e = e.parentElement) {
layers.unshift(e);
}
paint('white');
layers.forEach((layer) =>
paint(getComputedStyle(layer).backgroundColor)
);
const behind = luminance();
paint(getComputedStyle(element)[property]);
const front = luminance();
return (
(Math.max(front, behind) + 0.05) /
(Math.min(front, behind) + 0.05)
);
},
[property, ownFill] as const
);
}
/** Focus `from` and Tab (as that browser's user does) until `target` has focus. */
async function tabTo(
page: Page,
browserName: string,
from: Locator,
target: Locator,
maxTabs = 1
): Promise<void> {
await from.focus();
for (let i = 0; i < maxTabs; i++) {
await pressTab(page, browserName);
if (await target.evaluate((el) => el === document.activeElement)) {
break;
}
}
await expect(target).toBeFocused();
}
/**
* The ring sits inside the row (offset -2px), so it must stand out from the
* row's own fill and from the surface around the row.
*/
async function expectFocusRing(row: Locator): Promise<void> {
await expect(row).toBeFocused();
await expect(row).toHaveCSS('outline-style', 'solid');
await expect(row).toHaveCSS('outline-width', '2px');
const inside = await contrastBehind(row, 'outlineColor', true);
const outside = await contrastBehind(row, 'outlineColor', false);
expect(Math.min(inside, outside)).toBeGreaterThanOrEqual(3);
}
/** Colour and fill of a lock toggle, to tell locked from unlocked. */
async function toggleLook(toggle: Locator): Promise<string> {
return toggle.evaluate((el) => {
const style = getComputedStyle(el);
return `${style.color} ${style.backgroundColor}`;
});
}
async function expectPaintedBorder(
locator: Locator,
style: 'dashed' | 'solid'
): Promise<void> {
await expect(locator).toHaveCSS('border-top-style', style);
const { border } = await surfaceContrast(locator);
// 1 means the border colour is transparent against its own fill.
expect(border).toBeGreaterThan(1.15);
}
async function expectDialogFitsPhone(
page: Page,
dialog: Locator
): Promise<void> {
await page.setViewportSize(PHONE);
await expect
.poll(async () => {
const box = await dialog.boundingBox();
return box ? box.x >= 0 && box.x + box.width <= PHONE.width : false;
})
.toBe(true);
// A min-width on the content overflows the capped panel without
// widening the panel or the document, so measure the blocks inside.
const overflow = await dialog.evaluate((panel) => {
const edge = panel.getBoundingClientRect().right;
const blocks = panel.querySelectorAll(
'mat-dialog-content, mat-dialog-actions, .header-actions, ' +
'.search-inline, .categories-list'
);
return Math.max(
...Array.from(
blocks,
(block) => block.getBoundingClientRect().right - edge
)
);
});
expect(overflow).toBeLessThanOrEqual(0.5);
expect(
await page.evaluate(() => document.documentElement.scrollWidth)
).toBeLessThanOrEqual(PHONE.width);
}
test.describe('Parental lock UI', () => {
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
});
test('@parental @xtream PWA dialog, locked-categories row and PIN error', async ({
page,
browserName,
}) => {
test.slow();
await addXtreamPortal(page, 'Lock UI Portal');
const portalUrl = page.url();
await enableParentalLock(page);
// A reload starts locked: the unlock lives in memory only.
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-category-management-dialog'
);
const toggles = dialog.locator('[data-test-id^="category-lock-"]');
await expect(toggles.first()).toBeVisible();
// PWA: locks only, no hide/show selection controls.
await expect(
dialog.locator('[data-test-id="category-locked-count"]')
).toBeVisible();
await expect(dialog.locator('.bulk-actions')).toHaveCount(0);
await expect(dialog.locator('mat-checkbox')).toHaveCount(0);
await dialog.locator('.search-inline input').fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(dialog.locator('.search-inline input')).toHaveValue('');
// Locked reads as a state, not only as another glyph.
await toggles.first().click();
await expect(toggles.first()).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggles.first())).not.toBe(
await toggleLook(toggles.nth(1))
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator(
'[data-test-id="context-locked-categories"]'
);
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.context-panel .category-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
// A wrong PIN reads as an error in both themes.
await lockedRow.click();
await enterPin(page, '0000');
const error = page.locator('[data-test-id="parental-lock-pin-error"]');
await expect(error).toBeVisible();
for (const theme of THEMES) {
await setTheme(page, theme);
const [r, g, b] = await error.evaluate((el) =>
(getComputedStyle(el).color.match(/\d+/g) ?? []).map(Number)
);
expect(r - Math.max(g, b)).toBeGreaterThan(50);
expect(await contrastBehind(error, 'color', true)).toBeGreaterThan(
4.5
);
}
await enterPin(page);
await expect(lockedRow).toBeHidden();
await manage.click();
await expect(toggles.first()).toBeVisible();
await expectDialogFitsPhone(page, dialog);
});
test('@parental @stalker lock dialog rows, list border and phone width', async ({
page,
browserName,
}) => {
test.slow();
await addStalkerPortal(page, 'Lock UI Stalker');
const portalUrl = page.url();
await enableParentalLock(page);
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-stalker-category-lock-dialog'
);
const rows = dialog.locator('.category-item');
await expect(rows.first()).toBeVisible();
const search = dialog.locator('.search-inline input');
await tabTo(page, browserName, search, rows.first());
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(rows.first());
await expectPaintedBorder(
dialog.locator('.categories-list'),
'solid'
);
}
// The next row takes the ring over when Tab moves on.
await pressTab(page, browserName);
await expectFocusRing(rows.nth(1));
await search.fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(search).toHaveValue('');
await expectDialogFitsPhone(page, dialog);
});
test('@parental @m3u locked-groups row', async ({ page, browserName }) => {
test.slow();
// The fixture's placeholder media hosts stay offline.
await page.route(
/^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//,
(route) => route.abort()
);
await page.getByRole('button', { name: 'Add playlist' }).click();
const addDialog = page.locator('mat-dialog-container');
await addDialog.getByRole('radio', { name: /M3U file/i }).click();
await page.setInputFiles('input[type="file"]', M3U_FIXTURE);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
addDialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
const groupsUrl = page.url().replace(/\/all$/, '/groups');
await enableParentalLock(page);
await page.goto(groupsUrl);
const manage = page.locator('.groups-nav-action--manage');
await expect(manage).toBeVisible({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(page, 'app-group-management-dialog');
const toggle = dialog.locator('[data-test-id="group-lock-News"]');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggle)).not.toBe(
await toggleLook(
dialog.locator('[data-test-id="group-lock-Sport"]')
)
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator('[data-test-id="groups-locked-row"]');
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.groups-nav-list .nav-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
});
});
+8 -1
View File
@@ -374,7 +374,8 @@ so a cancelled or refused PIN leaves it showing the real state.
the PIN. The Xtream dialog loads its candidates through the
capability-selected data source (`IXtreamDataSource.getAllCategories`,
which the PWA source answers from its session cache or the API), so PWA
users can set locks too; the hide/show checkboxes remain Electron-only.
users can set locks too; the hide/show checkboxes, the selection count
and the Select/Deselect-all actions remain Electron-only.
A deliberate non-choice: no dedicated lock button in the rail header
(the header already carries search, sort and manage, and the lock is a
category-management concern) and no keyword-based "adult" recognition
@@ -415,6 +416,12 @@ so a cancelled or refused PIN leaves it showing the real state.
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
- Styling uses app tokens only (the theme declares no `--mat-sys-*`); the
PIN error and the Stalker adult chip use a local red per theme. Keyboard
focus on a lock row is a 2px inset `--app-selection-color` ring, and the
lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content
min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts`
checks rings, borders, the PIN error and phone width in both themes.
## Startup footprint
@@ -47,6 +47,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -1,3 +1,13 @@
// App tokens only: the theme declares no Material system colours. The
// adult-chip red is local, with a value per theme (>= 4.5:1 on its tint).
:host {
--lock-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--lock-dialog-error-color: #ffb4ab;
}
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
@@ -7,7 +17,7 @@
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
.header-actions {
@@ -20,15 +30,16 @@
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
strong {
color: var(--mat-sys-primary);
color: var(--app-heading-color);
}
}
.bulk-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
button {
@@ -43,19 +54,19 @@
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
border: 1px solid var(--app-search-border);
background: var(--app-search-bg);
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-selection-color);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
input {
@@ -63,7 +74,7 @@
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.84rem;
min-width: 0;
@@ -92,7 +103,7 @@
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border: 1px solid var(--app-separator);
border-radius: 8px;
}
@@ -105,29 +116,36 @@
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
background-color: var(--app-card-hover-bg);
}
// The global reset strips every button outline; an inset ring keeps
// the focused row visible inside the list's clipped scroll box.
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
border-bottom: 1px solid var(--app-widget-header-border);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
}
}
@@ -144,18 +162,22 @@
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
background: color-mix(
in srgb,
var(--lock-dialog-error-color) 16%,
transparent
);
color: var(--lock-dialog-error-color);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.8125rem;
}
// No min-width: the opener caps the panel at the viewport on a phone.
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -65,4 +65,16 @@ describe('StalkerCategoryLockDialogComponent', () => {
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('new');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
});
@@ -7,48 +7,66 @@
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
</div>
} @else {
<div class="header-actions">
<div class="selection-info">
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks()) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
<!-- Hide/show is Electron-only: the PWA lists categories for their
lock toggles alone, so it shows no selection count or bulk
selection actions. -->
@if (supportsVisibility || showLocks()) {
<div class="header-actions">
<div class="selection-info">
@if (supportsVisibility) {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> /
{{ totalCount() }}
}
@if (showLocks()) {
<span
class="selection-info__locks"
data-test-id="category-locked-count"
>
@if (supportsVisibility) {
·
}
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
@if (supportsVisibility) {
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="
isSaving() || filteredSelectedCount() === 0
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="isSaving() || filteredSelectedCount() === 0"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
</div>
}
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
@@ -66,6 +84,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -155,7 +155,6 @@
:host ::ng-deep {
.mat-mdc-dialog-content {
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -179,7 +178,7 @@ mat-dialog-actions {
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -188,7 +187,10 @@ mat-dialog-actions {
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -1,6 +1,7 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
@@ -37,6 +38,7 @@ describe('CategoryManagementDialogComponent', () => {
getAllCategories: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const snackBar = { open: jest.fn() };
const parentalLock = {
enabled: signal(false),
active: signal(false),
@@ -50,13 +52,8 @@ describe('CategoryManagementDialogComponent', () => {
itemCounts: new Map(),
};
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
data.contentType = 'live';
/** `supportsVisibility` false is the PWA: no SQLite hide/show. */
async function createDialog(supportsVisibility: boolean): Promise<void> {
await TestBed.configureTestingModule({
imports: [
CategoryManagementDialogComponent,
@@ -67,11 +64,14 @@ describe('CategoryManagementDialogComponent', () => {
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
useValue: {
supportsXtreamSqliteDataSource: supportsVisibility,
},
},
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: snackBar },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
@@ -79,6 +79,17 @@ describe('CategoryManagementDialogComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
parentalLock.setXtreamLocks.mockResolvedValue(true);
data.contentType = 'live';
await createDialog(true);
});
function selectedIds() {
@@ -253,6 +264,119 @@ describe('CategoryManagementDialogComponent', () => {
]);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
parentalLock.setXtreamLocks.mockResolvedValueOnce(false);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
expect(component.isSaving()).toBe(false);
});
it('does not blame the locks when the visibility write fails', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
db.updateCategoryVisibility.mockRejectedValueOnce(new Error('db'));
await component.save();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'Failed to save category visibility',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('fr');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
describe('in the PWA, where hide/show is unavailable', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(true);
await createDialog(false);
});
function text(): string {
fixture.detectChanges();
return fixture.nativeElement.textContent;
}
it('shows only the lock controls, not the selection count or bulk actions', () => {
expect(component.showLocks()).toBe(true);
expect(bulkButtons()).toEqual([]);
expect(text()).not.toContain('XTREAM.CATEGORY_MANAGEMENT.SELECTED');
expect(
fixture.nativeElement.querySelector('mat-checkbox')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="category-locked-count"]'
).textContent
).toContain('PARENTAL_LOCK.LOCKED_COUNT');
expect(
fixture.nativeElement.querySelectorAll('.lock-toggle')
).toHaveLength(categories.length);
});
it('shows no header at all while the lock feature is off', async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(false);
await createDialog(false);
expect(
fixture.nativeElement.querySelector('.header-actions')
).toBeNull();
});
it('saves the lock draft and never writes visibility', async () => {
component.toggleLock(component.categories()[1]);
await component.save();
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith(
'mock-playlist',
'live',
[102]
);
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.setXtreamLocks.mockRejectedValueOnce(
new Error('store')
);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
});
it.each([
['live', 'live'],
['vod', 'movies'],
@@ -18,7 +18,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
@@ -67,6 +67,7 @@ export class CategoryManagementDialogComponent implements OnInit {
*/
readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource;
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly dialogRef = inject(
MatDialogRef<CategoryManagementDialogComponent>
);
@@ -235,47 +236,74 @@ export class CategoryManagementDialogComponent implements OnInit {
this.isSaving.set(true);
try {
const categories = this.categories();
const toHide = categories
.filter((c) => !c.selected)
.map((c) => c.id);
const toShow = categories
.filter((c) => c.selected)
.map((c) => c.id);
if (this.supportsVisibility && toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
try {
await this.saveVisibility(categories);
} catch (error) {
this.logger.error('Error saving category visibility', error);
// No translation key exists for this message yet.
this.showSaveFailure('Failed to save category visibility');
return;
}
if (this.supportsVisibility && toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
// A relock while the visibility writes ran closed the dialog:
// its lock draft is dropped. The lock store refuses a removal
// that still commits after a relock (it checks at commit time).
if (this.showLocks() && !this.parentalLock.active()) {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories
.filter((c) => c.lockedDraft)
.map((c) => c.xtream_id)
if (!(await this.saveLocks(categories))) {
this.showSaveFailure(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED')
);
if (!saved) {
throw new Error('Saving category locks failed');
}
return;
}
this.dialogRef.close(true);
} catch (error) {
this.logger.error('Error saving category visibility', error);
this.snackBar.open('Failed to save category visibility', 'Close', {
duration: 3000,
});
} finally {
this.isSaving.set(false);
}
}
private async saveVisibility(
categories: CategoryWithSelection[]
): Promise<void> {
if (!this.supportsVisibility) {
return;
}
const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
const toShow = categories.filter((c) => c.selected).map((c) => c.id);
if (toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
}
if (toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
}
/** False only when a lock write was attempted and did not persist. */
private async saveLocks(
categories: CategoryWithSelection[]
): Promise<boolean> {
// A relock while the visibility writes ran closed the dialog: its
// lock draft is dropped. The lock store refuses a removal that
// still commits after a relock (it checks at commit time).
if (!this.showLocks() || this.parentalLock.active()) {
return true;
}
try {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories.filter((c) => c.lockedDraft).map((c) => c.xtream_id)
);
if (!saved) {
this.logger.error('Category locks were not saved');
}
return saved;
} catch (error) {
this.logger.error('Error saving category locks', error);
return false;
}
}
private showSaveFailure(message: string): void {
this.snackBar.open(message, this.translate.instant('CLOSE'), {
duration: 3000,
});
}
cancel(): void {
this.dialogRef.close(false);
}
@@ -48,6 +48,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -3,7 +3,6 @@
gap: 10px;
flex-direction: column;
overflow: hidden !important;
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -159,7 +158,7 @@
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -168,7 +167,10 @@
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -337,20 +337,26 @@
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -538,6 +538,7 @@ describe('GroupsViewComponent', () => {
]),
}),
maxHeight: '90vh',
maxWidth: 'calc(100vw - 32px)',
width: '500px',
})
);
@@ -579,6 +579,7 @@ export class GroupsViewComponent {
...(lockedGroupTitles ? { lockedGroupTitles } : {}),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
@@ -1,3 +1,13 @@
// The theme declares no Material system colours, so the error colour is a
// local token with a value per theme (>= 6:1 on the dialog surface in both).
:host {
--pin-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--pin-dialog-error-color: #ffb4ab;
}
.pin-dialog__title {
display: flex;
align-items: center;
@@ -12,7 +22,7 @@
.pin-dialog__description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.875rem;
}
@@ -31,14 +41,14 @@
.pin-dialog__error {
margin: 0;
color: var(--mat-sys-error);
color: var(--pin-dialog-error-color);
font-size: 0.8125rem;
}
.pin-dialog__note {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
@keyframes pin-dialog-shake {
@@ -137,6 +137,9 @@
type="button"
mat-icon-button
class="context-search__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
@@ -271,20 +271,26 @@ app-workspace-context-category-view {
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -963,6 +963,39 @@ describe('WorkspaceContextPanelComponent', () => {
expect(dialog.open).not.toHaveBeenCalled();
});
it('opens the Xtream category dialog capped at the viewport width', async () => {
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'xtream-1',
});
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
const component = fixture.componentInstance;
Object.defineProperty(component, 'canManageXtreamCategories', {
configurable: true,
value: () => true,
});
jest.spyOn(
TestBed.inject(ParentalLockService),
'requestUnlock'
).mockResolvedValue(true);
component.loadXtreamCategoryDialog = jest.fn(
async () => class DialogStub {}
);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(false) });
await component.openManageCategories();
// A 500px panel on a 375px phone would scroll sideways.
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
width: '500px',
maxWidth: 'calc(100vw - 32px)',
})
);
});
describe('Stalker lock dialog', () => {
function enableLock(): void {
const parentalLock = TestBed.inject(ParentalLockService);
@@ -1001,6 +1034,7 @@ describe('WorkspaceContextPanelComponent', () => {
{ category_id: '1', category_name: 'News' },
],
},
maxWidth: 'calc(100vw - 32px)',
})
);
});
@@ -567,6 +567,7 @@ export class WorkspaceContextPanelComponent {
this.dialog.open(dialogComponent, {
data: { playlistId: context.playlistId, contentType, categories },
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
}
@@ -620,6 +621,7 @@ export class WorkspaceContextPanelComponent {
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
dialogRef