feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than holding a copy of the defaults, which would be the copy that shipped in the binary rather than the one being edited. Hiding takes away the nav item and nothing else: `navigate` still resolves a hidden view, which detail views and the launch page depend on. No special case was needed for the highlight, because #72 moved that onto `active-view-store` -- the sidebar asks `isActive(id)` per *rendered* item, so a hidden view lights nothing exactly as a detail view does. Downloads is gated at the nav on `downloadStore.available` rather than in the config, so switching it on in Settings still means what it says once a client exists, and the tab appears without a restart. `available` is false until the providers have loaded, which makes the item appear on a fresh launch rather than appearing and then vanishing. The tab bar honours the toggles too, and the reason is local rather than a general rule about phones: "More" opens the *same* `<app-sidebar>`, which filters, so an unfiltered bar would contradict its own drawer one tap away. Which four tabs is still plan 016's subset; this only removes from it, and "More" is never filtered. `services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s pattern, because Settings is now a second reader of the same labels in the same order. Two existing sidebar tests had to say which world they describe: eleven destinations now assumes a configured download client.
This commit is contained in:
@@ -8,6 +8,7 @@ import '../sidebar/app-sidebar.js';
|
||||
import { nameDialog } from '@utils/name-dialog';
|
||||
import { ICON_PLAYLIST } from '@utils/icon-language';
|
||||
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
||||
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
||||
|
||||
type View = 'home' | 'albums' | 'tracks' | 'playlists';
|
||||
|
||||
@@ -129,6 +130,22 @@ export class BottomNav extends LitElement {
|
||||
*/
|
||||
private activeCtrl = new ActiveViewController(this);
|
||||
|
||||
/**
|
||||
* The tab bar honours the sidebar's toggles (#25), and the reason is
|
||||
* inside this component rather than a general rule about phones.
|
||||
* `PHONE_COLUMN_IDS` is the precedent for "what a phone shows is a
|
||||
* different question", and it would apply here too -- except that
|
||||
* "More" opens the *same* `<app-sidebar>`, which filters. An
|
||||
* unfiltered bar would therefore contradict its own drawer, one tap
|
||||
* apart, and a destination the user switched off is off wherever it
|
||||
* is offered.
|
||||
*
|
||||
* Which four tabs remains plan 016's committed subset; this only
|
||||
* removes from it. Hiding all four leaves "More", which is always
|
||||
* present and reaches everything.
|
||||
*/
|
||||
private visibilityCtrl = new ViewVisibilityController(this);
|
||||
|
||||
/**
|
||||
* Whether the drawer has been asked for.
|
||||
*
|
||||
@@ -211,7 +228,9 @@ export class BottomNav extends LitElement {
|
||||
return html`
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
${BottomNav.TABS.map((tab) => html`
|
||||
${BottomNav.TABS
|
||||
.filter((tab) => this.visibilityCtrl.visible(tab.id))
|
||||
.map((tab) => html`
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -27,6 +27,9 @@ import type * as library from '@go/library/models.js';
|
||||
import { ThemeController } from '@store/controllers/theme-controller';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
||||
import { VIEW_META } from '../../services/view-meta';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { GetAllPlaylists } from '@go/playlist/service.js';
|
||||
import type * as playlist from '@go/playlist/models.js';
|
||||
import { Events } from '../../events';
|
||||
@@ -71,6 +74,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
// --- Favorites controller ---
|
||||
private favCtrl = new FavoritesController(this);
|
||||
|
||||
/** Which destinations the navigation offers (#25). */
|
||||
private viewsCtrl = new ViewVisibilityController(this);
|
||||
|
||||
// --- Shortcuts controller ---
|
||||
private shortcutsCtrl = new ShortcutsController(this);
|
||||
|
||||
@@ -469,6 +475,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.view-note {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-font-size-sm, 0.85rem);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.column-arrows {
|
||||
display: flex;
|
||||
gap: 0.15em;
|
||||
@@ -1084,6 +1096,25 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewToggle = (
|
||||
view: string,
|
||||
visible: boolean,
|
||||
): void => {
|
||||
this.viewsCtrl
|
||||
.setVisible(view, visible)
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to save view visibility:', err);
|
||||
notificationStore.transient({
|
||||
key: 'view-visibility',
|
||||
text: `Could not change which views are shown. ${describeError(err)}`,
|
||||
detail: String(err),
|
||||
});
|
||||
// The checkbox has already flipped itself; the store is
|
||||
// the truth, so redraw from it.
|
||||
this.requestUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
private handleDefaultPageChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
@@ -1428,6 +1459,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
-->
|
||||
${this.renderLibrarySection()}
|
||||
${this.renderGeneralSection()}
|
||||
${this.renderNavigationSection()}
|
||||
${this.renderNowPlayingSection()}
|
||||
${this.renderThemeSection()}
|
||||
${this.renderTrackListSection()}
|
||||
@@ -1678,6 +1710,79 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Navigation section ---
|
||||
|
||||
/**
|
||||
* Which destinations the sidebar and the phone's tab bar offer.
|
||||
*
|
||||
* Two items are drawn but not editable, and both say why in place
|
||||
* rather than being silently inert. Settings is never hideable --
|
||||
* the backend refuses it too, because `config.toml` is
|
||||
* hand-editable. The launch page is not hideable *while it is the
|
||||
* launch page*, which is a state the user can leave by changing the
|
||||
* launch page above; refusing is preferable to the alternatives,
|
||||
* since resetting their launch page silently changes a second thing
|
||||
* they chose and allowing it lands the app on a page nothing points
|
||||
* at.
|
||||
*/
|
||||
private renderNavigationSection() {
|
||||
return html`
|
||||
<config-section
|
||||
heading="Navigation"
|
||||
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
|
||||
>
|
||||
<ul class="column-list">
|
||||
${repeat(VIEW_META, (v) => v.id, (v) => {
|
||||
const checked = this.viewsCtrl.enabled(v.id);
|
||||
const isLaunchPage = this.defaultPage === v.id;
|
||||
const locked = v.alwaysShown === true || isLaunchPage;
|
||||
|
||||
let note = '';
|
||||
|
||||
if (v.alwaysShown === true) {
|
||||
note = 'Always shown.';
|
||||
} else if (isLaunchPage) {
|
||||
note = 'This is the launch page.';
|
||||
} else if (
|
||||
v.id === 'downloads' &&
|
||||
checked &&
|
||||
!downloadStore.available
|
||||
) {
|
||||
// The config says show it and the nav does not, which
|
||||
// would otherwise read as the checkbox not working.
|
||||
note = 'Hidden until a download client is configured.';
|
||||
}
|
||||
|
||||
return html`
|
||||
<li
|
||||
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="column-toggle"
|
||||
aria-label="Show ${v.label} in the navigation"
|
||||
.checked=${checked}
|
||||
?disabled=${locked}
|
||||
@change=${(e: Event) =>
|
||||
this.handleViewToggle(
|
||||
v.id,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
<span class="column-label">
|
||||
${v.label}
|
||||
</span>
|
||||
${note
|
||||
? html`<span class="view-note">${note}</span>`
|
||||
: nothing}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Theme section ---
|
||||
|
||||
private renderThemeSection() {
|
||||
|
||||
@@ -5,19 +5,9 @@ import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
||||
import {
|
||||
ICON_PLAYLIST,
|
||||
ICON_AUTOTAG,
|
||||
ICON_REQUESTED,
|
||||
} from '@utils/icon-language';
|
||||
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
||||
import { VIEW_META } from '../../services/view-meta';
|
||||
import type { View } from '../../services/view-meta';
|
||||
|
||||
const MIN_WIDTH = 56;
|
||||
const MAX_WIDTH = 400;
|
||||
@@ -210,19 +200,15 @@ export class AppSidebar extends LitElement {
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
|
||||
private navItems: NavItem[] = [
|
||||
{ id: 'home', label: 'Home', icon: 'house' },
|
||||
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
];
|
||||
/**
|
||||
* Which destinations the user has kept (#25). The list below is
|
||||
* still the whole set and its order -- this only filters it, and
|
||||
* only for drawing: a hidden view is still reachable by `navigate`,
|
||||
* which is what detail views and the launch page depend on.
|
||||
*/
|
||||
private visibilityCtrl = new ViewVisibilityController(this);
|
||||
|
||||
private navItems = VIEW_META;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -283,7 +269,9 @@ export class AppSidebar extends LitElement {
|
||||
></div>
|
||||
<nav aria-label="Main">
|
||||
<ul>
|
||||
${this.navItems.map((item) => {
|
||||
${this.navItems
|
||||
.filter((item) => this.visibilityCtrl.visible(item.id))
|
||||
.map((item) => {
|
||||
const active = this.activeCtrl.isActive(item.id);
|
||||
const classes = [
|
||||
active
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ICON_PLAYLIST,
|
||||
ICON_AUTOTAG,
|
||||
ICON_REQUESTED,
|
||||
} from '@utils/icon-language';
|
||||
|
||||
/** A primary destination. Mirrors `backend/config.View`. */
|
||||
export type View =
|
||||
| 'home'
|
||||
| 'playlists'
|
||||
| 'artists'
|
||||
| 'genres'
|
||||
| 'albums'
|
||||
| 'tracks'
|
||||
| 'explore'
|
||||
| 'downloads'
|
||||
| 'autotag'
|
||||
| 'jobs'
|
||||
| 'settings';
|
||||
|
||||
export interface ViewMeta {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: string;
|
||||
/**
|
||||
* Views that are never offered as a toggle. Settings alone, because
|
||||
* a user who hides it cannot get back to unhide it.
|
||||
*
|
||||
* This is the *affordance*; the rule is `backend/config.ViewSpec`'s
|
||||
* `Hideable`, which refuses at the setter and drops the key on load.
|
||||
* `config.toml` is hand-editable, so the checkbox being absent is
|
||||
* not what makes this safe — it is only what stops the question
|
||||
* being asked.
|
||||
*/
|
||||
alwaysShown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's primary destinations, in the order the navigation draws
|
||||
* them and Settings lists them.
|
||||
*
|
||||
* It is here rather than inside `app-sidebar` because #25 gave it a
|
||||
* second reader: Settings renders a toggle per view and needs the same
|
||||
* labels in the same order. Same shape as `services/shortcut-meta.ts`,
|
||||
* which moved out of `config-page` for the same reason -- a private
|
||||
* static that two surfaces need is a private static that is about to be
|
||||
* copied.
|
||||
*
|
||||
* The labels and icons deliberately do not exist in Go. Which views
|
||||
* exist and what an unconfigured install shows is `backend/config.Views`
|
||||
* and is asked for over the binding; how they are *drawn* is the
|
||||
* frontend's, and lives beside the rest of the icon vocabulary.
|
||||
*/
|
||||
export const VIEW_META: ViewMeta[] = [
|
||||
{ id: 'home', label: 'Home', icon: 'house' },
|
||||
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from 'lit';
|
||||
import { viewVisibilityStore } from '../view-visibility-store';
|
||||
|
||||
/**
|
||||
* ViewVisibilityController connects a Lit component to the
|
||||
* ViewVisibilityStore.
|
||||
*
|
||||
* It reads through to the store rather than copying the map into a
|
||||
* `@state()` field, for the reason `ActiveViewController` does: there
|
||||
* are two live `<app-sidebar>` instances the moment `bottom-nav`'s
|
||||
* "More" drawer opens, and two components holding their own idea of
|
||||
* which destinations exist is how they come to disagree.
|
||||
*/
|
||||
export class ViewVisibilityController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = viewVisibilityStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
|
||||
void viewVisibilityStore.init();
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
/** Whether the navigation should offer this destination. */
|
||||
visible(view: string): boolean {
|
||||
return viewVisibilityStore.visible(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the config says, ignoring the download-client gate — the
|
||||
* state Settings' own checkbox shows.
|
||||
*/
|
||||
enabled(view: string): boolean {
|
||||
return viewVisibilityStore.enabled(view);
|
||||
}
|
||||
|
||||
setVisible(view: string, visible: boolean): Promise<void> {
|
||||
return viewVisibilityStore.setVisible(view, visible);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,8 @@ class DownloadStore {
|
||||
|
||||
private initialized = false;
|
||||
|
||||
private providersLoaded = false;
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.DownloadProvidersChanged, () => {
|
||||
void this.refreshProviders();
|
||||
@@ -285,6 +287,25 @@ class DownloadStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the providers, and only those, once.
|
||||
*
|
||||
* `init()` additionally fetches the descriptors, the downloads and
|
||||
* the request list, which is right for a page about downloading and
|
||||
* wrong for the sidebar: it only needs `available`, to decide
|
||||
* whether the Downloads destination exists at all (#25), and that
|
||||
* is one query. `DownloadProvidersChanged` keeps it current
|
||||
* afterwards, so configuring a client makes the tab appear without
|
||||
* a restart.
|
||||
*/
|
||||
async ensureProviders(): Promise<void> {
|
||||
if (this.providersLoaded) return;
|
||||
|
||||
this.providersLoaded = true;
|
||||
|
||||
await this.refreshProviders();
|
||||
}
|
||||
|
||||
async refreshProviders(): Promise<void> {
|
||||
try {
|
||||
this.providersValue = (await ListProviders()) ?? [];
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { GetViewVisibility, SetViewVisible } from '@go/config/config.js';
|
||||
import { dictByName } from '@utils/binding';
|
||||
import { downloadStore } from './download-store';
|
||||
import { Events } from '../events';
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
/**
|
||||
* Which primary destinations the navigation offers.
|
||||
*
|
||||
* Eleven sidebar entries is more than most libraries need, so #25 makes
|
||||
* them individually toggleable. Three rules about this are load-bearing.
|
||||
*
|
||||
* **Hidden is not unreachable.** This decides what the *nav* draws and
|
||||
* nothing else: `navigate` still resolves a hidden view, which is not a
|
||||
* nicety — detail views navigate into these, and the shell's launch
|
||||
* page is one of them. Nothing here needs a special case for the
|
||||
* highlight either, because #72 moved that onto `active-view-store`:
|
||||
* `app-sidebar` asks `isActive(id)` per *rendered* item, so a hidden
|
||||
* view lights nothing exactly as a detail view does.
|
||||
*
|
||||
* **The defaults live in Go**, in `backend/config.Views`, and this asks
|
||||
* for the *resolved* answer rather than the stored map. A config that
|
||||
* says nothing about a view means "that view's own default", so a copy
|
||||
* of the defaults here would be a second thing to keep in step — and
|
||||
* the one that shipped in the artifact, not the one being edited.
|
||||
*
|
||||
* **Downloads is a second question**, answered by the download client
|
||||
* rather than by the config: a destination for a feature that cannot
|
||||
* work is worse than an absent one. It is gated at `visible()` and not
|
||||
* in the config, so switching it on in Settings still means what it
|
||||
* says once a client exists. `available` is false until the providers
|
||||
* have loaded, which makes the tab *appear* on a fresh launch rather
|
||||
* than appearing and then vanishing — the less jarring half of a race
|
||||
* that resolves in one query.
|
||||
*/
|
||||
class ViewVisibilityStore {
|
||||
/** The backend's resolved answer, empty until the first load. */
|
||||
private configured: Record<string, boolean> = {};
|
||||
|
||||
private loaded = false;
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.GeneralConfigChanged, () => {
|
||||
void this.refresh();
|
||||
});
|
||||
|
||||
// A client configured later has to add the destination without a
|
||||
// restart -- #37's rule, one surface over.
|
||||
downloadStore.subscribe(() => this.notify());
|
||||
}
|
||||
|
||||
/** Loads the visibility map once. Safe to call from every mount. */
|
||||
async init(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
|
||||
this.loaded = true;
|
||||
|
||||
await Promise.all([
|
||||
this.refresh(),
|
||||
downloadStore.ensureProviders(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the navigation should offer this destination.
|
||||
*
|
||||
* An unknown id is visible: the caller is drawing it from its own
|
||||
* list, and a view this store has not heard of (or has not loaded
|
||||
* yet) is better shown than silently dropped.
|
||||
*/
|
||||
visible(view: string): boolean {
|
||||
if (view === 'downloads' && !downloadStore.available) return false;
|
||||
|
||||
return this.configured[view] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the *config* says, ignoring the download-client gate — which
|
||||
* is what Settings' own checkbox has to show, or a user with no
|
||||
* client would see Downloads switched off and be unable to switch
|
||||
* it on.
|
||||
*/
|
||||
enabled(view: string): boolean {
|
||||
return this.configured[view] ?? true;
|
||||
}
|
||||
|
||||
async setVisible(view: string, visible: boolean): Promise<void> {
|
||||
await SetViewVisible(view, visible);
|
||||
|
||||
// The backend emits GeneralConfigChanged, but the caller is
|
||||
// owed the new state by the time this resolves.
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
subscribe(fn: Subscriber): () => void {
|
||||
this.subscribers.add(fn);
|
||||
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
this.configured = await dictByName(GetViewVisibility());
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load view visibility:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
|
||||
export const viewVisibilityStore = new ViewVisibilityStore();
|
||||
@@ -26,7 +26,26 @@ import {
|
||||
ICON_REQUESTED,
|
||||
} from '@utils/icon-language';
|
||||
|
||||
/** A configured, enabled download client. */
|
||||
const PROVIDER = {
|
||||
id: 1,
|
||||
kind: 'slskd',
|
||||
name: 'Sound',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
};
|
||||
|
||||
describe('<app-sidebar>', () => {
|
||||
// Downloads is offered only where there is a client to download with
|
||||
// (#25), so "all eleven destinations" is a statement about a
|
||||
// configured install. `view-visibility.test.ts` owns the rule itself;
|
||||
// this states the world these cases are describing.
|
||||
beforeEach(async () => {
|
||||
stub('download.Service.ListProviders', [PROVIDER]);
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('renders a testid per destination, which is how e2e navigates', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import '@components/track-list/track-list';
|
||||
import { stub } from '@test/support/harness';
|
||||
import { stub, emit, flush } from '@test/support/harness';
|
||||
import { Events } from '../../src/events';
|
||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||
|
||||
/** Two fixture tracks, enough to move a focus ring between. */
|
||||
@@ -33,6 +34,16 @@ const TRACKS = [
|
||||
] as never[];
|
||||
|
||||
describe('<app-sidebar> is reachable', () => {
|
||||
// Eleven destinations assumes a configured download client, since
|
||||
// Downloads is not offered without one (#25).
|
||||
beforeEach(async () => {
|
||||
stub('download.Service.ListProviders', [
|
||||
{ id: 1, kind: 'slskd', name: 'Sound', enabled: true, priority: 50 },
|
||||
]);
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('renders every destination as a button, not a bare list item', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Which destinations the navigation offers (#25).
|
||||
*
|
||||
* Eleven sidebar entries is more than most libraries need, so they are
|
||||
* individually toggleable. The assertions here are about the **nav**
|
||||
* and not about the setting being saved: "the config was written" is
|
||||
* the plumbing, and a spec that measures the plumbing is how #69 and
|
||||
* #72 both shipped green on a broken build.
|
||||
*
|
||||
* Two singletons make ordering matter, and both are driven the way the
|
||||
* app drives them rather than reset: `GeneralConfigChanged` is what the
|
||||
* backend emits when a toggle is saved, and `DownloadProvidersChanged`
|
||||
* is what it emits when a client is configured. So each case states the
|
||||
* world it wants and is independent of which one ran first.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/bottom-nav/bottom-nav';
|
||||
import { activeViewStore } from '@store/active-view-store';
|
||||
import { stub, emit, flush, resetHarness } from '@test/support/harness';
|
||||
import { Events } from '../../src/events';
|
||||
import { fixture, shadowAll } from '@test/support/render';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
const PROVIDER = {
|
||||
id: 1,
|
||||
kind: 'slskd',
|
||||
name: 'Sound',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
};
|
||||
|
||||
/** Every view id the sidebar is currently drawing, in order. */
|
||||
const navIDs = (el: HTMLElement) =>
|
||||
shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||
.map((b) => b.dataset.testid?.replace(/^nav-/, ''))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
|
||||
const tabIDs = (el: HTMLElement) =>
|
||||
shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||
.map((b) => b.dataset.testid?.replace(/^tab-/, ''))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
|
||||
/**
|
||||
* State the backend's resolved answer and push the event that says it
|
||||
* changed. The map is *resolved* — every known view, defaults already
|
||||
* applied — because that is what the binding returns and the whole
|
||||
* reason the frontend holds no copy of the defaults.
|
||||
*/
|
||||
async function setViews(views: Record<string, boolean>): Promise<void> {
|
||||
stub('config.Config.GetViewVisibility', views);
|
||||
emit(Events.GeneralConfigChanged, {});
|
||||
await flush();
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function setClientConfigured(configured: boolean): Promise<void> {
|
||||
stub('download.Service.ListProviders', configured ? [PROVIDER] : []);
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
await flush();
|
||||
}
|
||||
|
||||
const ALL_VISIBLE = {
|
||||
home: true,
|
||||
playlists: true,
|
||||
artists: true,
|
||||
genres: true,
|
||||
albums: true,
|
||||
tracks: true,
|
||||
explore: true,
|
||||
downloads: true,
|
||||
autotag: true,
|
||||
jobs: true,
|
||||
settings: true,
|
||||
};
|
||||
|
||||
describe('view visibility', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
await setViews(ALL_VISIBLE);
|
||||
await setClientConfigured(true);
|
||||
});
|
||||
|
||||
it('draws every destination the config keeps', async () => {
|
||||
const el = await fixture<LitElement>('app-sidebar');
|
||||
|
||||
expect(navIDs(el)).toEqual([
|
||||
'home',
|
||||
'playlists',
|
||||
'artists',
|
||||
'genres',
|
||||
'albums',
|
||||
'tracks',
|
||||
'explore',
|
||||
'downloads',
|
||||
'autotag',
|
||||
'jobs',
|
||||
'settings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the ones the user switched off', async () => {
|
||||
const el = await fixture<LitElement>('app-sidebar');
|
||||
|
||||
await setViews({ ...ALL_VISIBLE, autotag: false, jobs: false });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(navIDs(el)).not.toContain('autotag');
|
||||
expect(navIDs(el)).not.toContain('jobs');
|
||||
expect(navIDs(el)).toContain('settings');
|
||||
});
|
||||
|
||||
/**
|
||||
* Hiding is about the nav item, not about the view. Detail views
|
||||
* navigate into these and the launch page is one of them, so the
|
||||
* shell's own statement of where the user is has to survive a
|
||||
* destination that draws no item — and it does so with no special
|
||||
* case here, because #72 moved the highlight onto `active-view-store`
|
||||
* and this only filters what is rendered.
|
||||
*/
|
||||
it('lights nothing when the active view is a hidden one', async () => {
|
||||
const el = await fixture<LitElement>('app-sidebar');
|
||||
|
||||
await setViews({ ...ALL_VISIBLE, autotag: false });
|
||||
activeViewStore.setView('autotag', true);
|
||||
await el.updateComplete;
|
||||
|
||||
const lit = shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||
.filter((b) => b.getAttribute('aria-current') === 'page');
|
||||
|
||||
expect(lit).toHaveLength(0);
|
||||
expect(navIDs(el)).not.toContain('autotag');
|
||||
|
||||
activeViewStore.setView('albums', true);
|
||||
});
|
||||
|
||||
/**
|
||||
* A destination for a feature that cannot work is worse than an
|
||||
* absent one, so Downloads asks the download client rather than the
|
||||
* config — and it appears when one is configured, without a restart
|
||||
* (#37's rule, one surface over).
|
||||
*/
|
||||
it('hides Downloads until a client is configured', async () => {
|
||||
const el = await fixture<LitElement>('app-sidebar');
|
||||
|
||||
await setClientConfigured(false);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(navIDs(el)).not.toContain('downloads');
|
||||
|
||||
await setClientConfigured(true);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(navIDs(el)).toContain('downloads');
|
||||
});
|
||||
|
||||
/**
|
||||
* The tab bar honours the toggles too, and the reason is local: its
|
||||
* "More" drawer opens the same `<app-sidebar>`, which filters. An
|
||||
* unfiltered bar would contradict its own drawer one tap away.
|
||||
*/
|
||||
it('drops a hidden destination from the phone tab bar', async () => {
|
||||
const el = await fixture<LitElement>('bottom-nav');
|
||||
|
||||
expect(tabIDs(el)).toEqual(['home', 'albums', 'tracks', 'playlists', 'more']);
|
||||
|
||||
await setViews({ ...ALL_VISIBLE, albums: false });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(tabIDs(el)).toEqual(['home', 'tracks', 'playlists', 'more']);
|
||||
});
|
||||
|
||||
/** "More" is not a destination and is never filtered away: it is how
|
||||
* everything else is still reachable. */
|
||||
it('keeps More when every tab is hidden', async () => {
|
||||
const el = await fixture<LitElement>('bottom-nav');
|
||||
|
||||
await setViews({
|
||||
...ALL_VISIBLE,
|
||||
home: false,
|
||||
albums: false,
|
||||
tracks: false,
|
||||
playlists: false,
|
||||
});
|
||||
await el.updateComplete;
|
||||
|
||||
expect(tabIDs(el)).toEqual(['more']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user