mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
feat: show the settings page's shape while the app loads
This commit is contained in:
@@ -91,3 +91,41 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* settings */
|
||||
|
||||
/* The open settings page in the skeleton nav: the row whose
|
||||
data-sk-settings-tab matches data-boot-settings-tab. */
|
||||
@custom-variant boot-settings-active (&:where(
|
||||
[data-boot-settings-tab="account"] [data-sk-settings-tab="account"],
|
||||
[data-boot-settings-tab="general"] [data-sk-settings-tab="general"],
|
||||
[data-boot-settings-tab="notifications"] [data-sk-settings-tab="notifications"],
|
||||
[data-boot-settings-tab="appearance"] [data-sk-settings-tab="appearance"],
|
||||
[data-boot-settings-tab="accessibility"] [data-sk-settings-tab="accessibility"],
|
||||
[data-boot-settings-tab="message_list"] [data-sk-settings-tab="message_list"],
|
||||
[data-boot-settings-tab="reading"] [data-sk-settings-tab="reading"],
|
||||
[data-boot-settings-tab="composing"] [data-sk-settings-tab="composing"],
|
||||
[data-boot-settings-tab="vacation"] [data-sk-settings-tab="vacation"],
|
||||
[data-boot-settings-tab="filters"] [data-sk-settings-tab="filters"],
|
||||
[data-boot-settings-tab="organize"] [data-sk-settings-tab="organize"],
|
||||
[data-boot-settings-tab="security"] [data-sk-settings-tab="security"],
|
||||
[data-boot-settings-tab="sharing"] [data-sk-settings-tab="sharing"],
|
||||
[data-boot-settings-tab="recovery"] [data-sk-settings-tab="recovery"],
|
||||
[data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"],
|
||||
[data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"],
|
||||
[data-boot-settings-tab="files"] [data-sk-settings-tab="files"],
|
||||
[data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"],
|
||||
[data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"]
|
||||
));
|
||||
|
||||
/* Settings pages the nav listed last time (bulwark-boot-hints). Without
|
||||
hints every page is drawn except recovery (an Enterprise feature). */
|
||||
@custom-variant boot-no-settings-security (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="security"]) *));
|
||||
@custom-variant boot-no-settings-sharing (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="sharing"]) *));
|
||||
@custom-variant boot-no-settings-filters (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="filters"]) *));
|
||||
@custom-variant boot-no-settings-vacation (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="vacation"]) *));
|
||||
@custom-variant boot-no-settings-calendar (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="calendar"]) *));
|
||||
@custom-variant boot-no-settings-contacts (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="contacts"]) *));
|
||||
@custom-variant boot-no-settings-files (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="files"]) *));
|
||||
@custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *));
|
||||
@custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *));
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { SettingsSkeleton, SettingsSkeletonBody } from '../settings-skeleton';
|
||||
import { SETTINGS_TAB_IDS } from '@/lib/settings-tabs';
|
||||
|
||||
/**
|
||||
* The settings skeleton marks the open page and hides the pages the last
|
||||
* session's nav didn't have, both from attributes the boot script sets on
|
||||
* <html>. The selectors live in app/boot-skeleton.css as Tailwind variants;
|
||||
* read them from there and run them against the rendered rows.
|
||||
*/
|
||||
const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8');
|
||||
|
||||
/** The selector inside `@custom-variant <name> (&:where(<selector>))`. */
|
||||
function variantSelector(name: string): string {
|
||||
const match = css.match(new RegExp(`@custom-variant ${name} \\(&:where\\(([\\s\\S]*?)\\)\\);`));
|
||||
if (!match) throw new Error(`no variant ${name}`);
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
function mount(html: string) {
|
||||
document.body.innerHTML = html;
|
||||
return document.body;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('data-boot-settings-tab');
|
||||
document.documentElement.removeAttribute('data-boot-settings-tabs');
|
||||
});
|
||||
|
||||
describe('settings skeleton nav', () => {
|
||||
it('draws a row for every settings page', () => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const ids = [...root.querySelectorAll('[data-sk-settings-tab]')].map((el) => el.getAttribute('data-sk-settings-tab'));
|
||||
expect([...ids].sort()).toEqual([...SETTINGS_TAB_IDS].sort());
|
||||
});
|
||||
|
||||
it.each(SETTINGS_TAB_IDS)('highlights only the %s row when that page is open', (tab) => {
|
||||
const root = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
const active = variantSelector('boot-settings-active');
|
||||
document.documentElement.setAttribute('data-boot-settings-tab', tab);
|
||||
const rows = [...root.querySelectorAll<HTMLElement>('[data-sk-settings-tab]')];
|
||||
const matching = rows.filter((row) => row.matches(active)).map((row) => row.getAttribute('data-sk-settings-tab'));
|
||||
expect(matching).toEqual([tab]);
|
||||
const row = rows.find((r) => r.getAttribute('data-sk-settings-tab') === tab)!;
|
||||
expect(row.className).toContain('boot-settings-active:bg-accent');
|
||||
});
|
||||
|
||||
it('hides the optional pages the last nav did not list, and shows recovery only when it did', () => {
|
||||
const root = mount(renderToString(<SettingsSkeletonBody />));
|
||||
const row = (tab: string) => root.querySelector<HTMLElement>(`[data-sk-settings-tab="${tab}"]`)!;
|
||||
const hidesFilters = variantSelector('boot-no-settings-filters');
|
||||
const showsRecovery = variantSelector('boot-settings-recovery');
|
||||
expect(row('filters').className).toContain('boot-no-settings-filters:hidden');
|
||||
expect(row('recovery').className).toContain('boot-settings-recovery:flex');
|
||||
|
||||
// No hints: everything but recovery.
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account appearance recovery');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(true);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(true);
|
||||
|
||||
document.documentElement.setAttribute('data-boot-settings-tabs', 'account filters');
|
||||
expect(row('filters').matches(hidesFilters)).toBe(false);
|
||||
expect(row('recovery').matches(showsRecovery)).toBe(false);
|
||||
});
|
||||
|
||||
it('has the back-to-mail box only outside a Pro pane', () => {
|
||||
const page = mount(renderToString(<SettingsSkeleton label="Loading" />));
|
||||
expect(page.querySelector('.boot-pro\\:hidden')).not.toBeNull();
|
||||
const pane = mount(renderToString(<SettingsSkeletonBody embedded />));
|
||||
expect(pane.querySelector('.boot-pro\\:hidden')).toBeNull();
|
||||
// A Pro pane is desktop only: no phone list.
|
||||
expect(pane.querySelector('.boot-open\\:hidden')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import { ArrowLeft, ChevronRight, LogOut, MoreVertical, Search, Settings } from "@/components/icons";
|
||||
import { SETTINGS_NAV_GROUPS, SETTINGS_TAB_ICONS } from "@/components/settings/settings-tab-icons";
|
||||
import type { SettingsSearchTab } from "@/lib/settings-search";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Block, Circle, repeat, textWidth } from "./kit";
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface SettingsSkeletonProps {
|
||||
@@ -8,18 +13,287 @@ export interface SettingsSkeletonProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full).
|
||||
* Rows the nav may leave out, hidden when the last session's nav didn't have
|
||||
* them (`data-boot-settings-tabs`, app/boot-skeleton.css). Calendar and files
|
||||
* also follow the rail's hints. Recovery is an Enterprise feature, so it is
|
||||
* only drawn when the hints say it was there.
|
||||
*/
|
||||
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return <div className="flex-1 min-w-0" data-embedded={embedded ? "" : undefined} />;
|
||||
const OPTIONAL: Partial<Record<SettingsSearchTab, string>> = {
|
||||
security: "boot-no-settings-security:hidden",
|
||||
sharing: "boot-no-settings-sharing:hidden",
|
||||
filters: "boot-no-settings-filters:hidden",
|
||||
vacation: "boot-no-settings-vacation:hidden",
|
||||
recovery: "hidden boot-settings-recovery:flex",
|
||||
calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden",
|
||||
contacts: "boot-no-settings-contacts:hidden",
|
||||
files: "boot-no-files:hidden boot-no-settings-files:hidden",
|
||||
addons: "boot-no-settings-addons:hidden",
|
||||
};
|
||||
|
||||
/** A line of text as tall as its line box, so rows keep the real height. */
|
||||
function Line({ className, bar, w }: { className?: string; bar: string; w: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center h-[1lh]", className)}>
|
||||
<Bar className={bar} w={w} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the settings page. Stub: filled in per surface. */
|
||||
/** The search field (ToolbarSearch): an h-8 input with the magnifier at its start. */
|
||||
function SearchField() {
|
||||
return (
|
||||
<span className="relative block">
|
||||
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<span className="block h-8 w-full rounded-md border border-input bg-background" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Advanced switch row at the foot of the nav (AdvancedSettingsSwitch). */
|
||||
function AdvancedRow() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm">
|
||||
<Line bar="h-3" w="7rem" />
|
||||
<Block className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The desktop nav (settings-app.tsx, "Desktop layout"). */
|
||||
function SettingsNav({ embedded }: { embedded: boolean }) {
|
||||
let row = 0;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="border-e border-border bg-secondary flex flex-col shrink-0"
|
||||
style={{ width: "var(--boot-settings-nav-w, 256px)" }}
|
||||
>
|
||||
{embedded ? null : (
|
||||
// Back to mail; the Pro interface has none.
|
||||
<div className="border-b border-border boot-pro:hidden">
|
||||
<div className="flex items-center h-14 px-2">
|
||||
<span className="flex items-center w-full h-9 px-3 text-sm">
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
<Bar className="h-3" w="6rem" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden py-2">
|
||||
<div className="px-3 pt-1 pb-1">
|
||||
<SearchField />
|
||||
</div>
|
||||
<div className="px-3 space-y-0.5">
|
||||
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 ? <div className="mx-1 my-2 border-t border-border" /> : null}
|
||||
{group === "mail" || group === "apps" ? (
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
{/* An inline span in the real nav: the line box is the parent's. */}
|
||||
<Line bar="h-2" w="3rem" />
|
||||
</div>
|
||||
) : null}
|
||||
{tabs.map((tab) => {
|
||||
const Icon = SETTINGS_TAB_ICONS[tab];
|
||||
const i = row++;
|
||||
return (
|
||||
<div
|
||||
key={tab}
|
||||
data-sk-settings-tab={tab}
|
||||
className={cn(
|
||||
"flex w-full py-2 pe-3 ps-3 rounded-md text-sm items-center gap-2.5 text-muted-foreground",
|
||||
// The open page, from data-boot-settings-tab (app/boot-skeleton.css).
|
||||
"boot-settings-active:bg-accent boot-settings-active:text-accent-foreground",
|
||||
OPTIONAL[tab],
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<Line className="flex-1 min-w-0" bar="h-3" w={textWidth(i, 35, 35)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
<AdvancedRow />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settings page: the section jump row, then sections of rows (settings-section.tsx). */
|
||||
function SettingsPage({ sections }: { sections: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{repeat(4, (i) => (
|
||||
<Block className="h-8" w={`${4.5 + ((i * 3) % 4)}rem`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{repeat(sections, (s) => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Line className="text-lg" bar="h-4" w={textWidth(s, 20, 20)} />
|
||||
<Line className="text-sm mt-1" bar="h-3" w={textWidth(s + 3, 45, 35)} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{repeat(3, (r) => (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0">
|
||||
<div className="flex-1 min-w-0 sm:pe-4">
|
||||
<Line className="text-sm" bar="h-3" w={textWidth(s * 3 + r, 25, 25)} />
|
||||
<Line className="text-xs mt-1" bar="h-2.5" w={textWidth(s * 3 + r + 5, 45, 40)} />
|
||||
</div>
|
||||
<Block className={r % 2 ? "h-6 w-11 rounded-full" : "h-8 w-28"} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
|
||||
function PhoneHeader({ list }: { list: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<span className="grid place-items-center h-11 w-11 shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
{list ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
<Line className="text-lg" bar="h-4" w="5rem" />
|
||||
</span>
|
||||
) : (
|
||||
<Line className="text-lg" bar="h-4" w="7rem" />
|
||||
)}
|
||||
<span className="flex items-center gap-1 flex-shrink-0 ms-auto">
|
||||
<span className="grid place-items-center h-11 w-11">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The phone and tablet list of pages (settings-app.tsx, "Mobile layout"). */
|
||||
function PhoneList() {
|
||||
let row = 0;
|
||||
return (
|
||||
<div aria-hidden="true" className="flex flex-col flex-1 min-h-0 boot-open:hidden">
|
||||
<PhoneHeader list />
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<SearchField />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
{SETTINGS_NAV_GROUPS.map(({ group, tabs }, groupIndex) => (
|
||||
<div key={group}>
|
||||
{groupIndex > 0 ? <div className="mx-5 my-2 border-t border-border" /> : null}
|
||||
{group === "mail" || group === "apps" ? (
|
||||
<div className="px-5 pt-3 pb-1.5">
|
||||
{/* An inline span in the real nav: the line box is the parent's. */}
|
||||
<Line bar="h-2" w="3rem" />
|
||||
</div>
|
||||
) : null}
|
||||
{tabs.map((tab) => {
|
||||
const Icon = SETTINGS_TAB_ICONS[tab];
|
||||
const i = row++;
|
||||
return (
|
||||
<div
|
||||
key={tab}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-muted-foreground",
|
||||
OPTIONAL[tab],
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<Line className="flex-1" bar="h-3" w={textWidth(i, 30, 30)} />
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<AdvancedRow />
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<span className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive px-2">
|
||||
<LogOut className="w-4 h-4" />
|
||||
<Line bar="h-3" w="4rem" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One settings page on the phone or tablet, when the URL names one. */
|
||||
function PhonePage() {
|
||||
return (
|
||||
<div aria-hidden="true" className="hidden boot-open:flex flex-col flex-1 min-h-0">
|
||||
<PhoneHeader list={false} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden p-4">
|
||||
<SettingsPage sections={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings page's own content: everything in the row next to the rail.
|
||||
* `embedded` is the variant drawn inside a Pro pane (useIsEmbedded()), which
|
||||
* has no rail, no Mountain View shell and fills its pane (h-full); it has no
|
||||
* back-to-mail box either, and the Pro shell is desktop only.
|
||||
*/
|
||||
export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex-1 min-w-0 min-h-0 flex", embedded ? "h-full" : "flex-col lg:flex-row")}
|
||||
data-embedded={embedded ? "" : undefined}
|
||||
>
|
||||
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
|
||||
<SettingsNav embedded={embedded} />
|
||||
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<SettingsPage sections={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{embedded ? null : (
|
||||
<div className="flex flex-col flex-1 min-h-0 lg:hidden">
|
||||
<PhoneList />
|
||||
<PhonePage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton of the settings page. */
|
||||
export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="settings" label={label} labels={labels} railBelow="lg" active="settings">
|
||||
<AppShellSkeleton
|
||||
surface="settings"
|
||||
label={label}
|
||||
labels={labels}
|
||||
railBelow="lg"
|
||||
active="settings"
|
||||
// Mountain View: the page sits on a card inset from the canvas.
|
||||
rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
|
||||
>
|
||||
<SettingsSkeletonBody />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Until the session is up, Settings draws its page skeleton (the one the
|
||||
// server drew before any script ran) instead of nothing, and the nav waits
|
||||
// for the client so it renders once with the account's pages.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
|
||||
useMessages: () => ({}),
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
|
||||
atLeast: (await importOriginal<typeof import('@/hooks/use-media-query')>()).atLeast,
|
||||
useIsDesktop: () => true,
|
||||
useIsMobile: () => false,
|
||||
useMediaQuery: () => false,
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
|
||||
|
||||
const { SettingsApp } = await import('../settings-app');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { usePluginStore } = await import('@/stores/plugin-store');
|
||||
const { useThemeStore } = await import('@/stores/theme-store');
|
||||
const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons');
|
||||
const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys');
|
||||
const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints');
|
||||
|
||||
/** A client that supports everything it is asked about. */
|
||||
const fullClient = new Proxy({}, {
|
||||
get: (_t, prop) => {
|
||||
if (prop === 'then') return undefined;
|
||||
if (prop === 'getAccountId') return () => 'acc';
|
||||
if (typeof prop === 'string' && prop.startsWith('supports')) return () => true;
|
||||
if (prop === 'getShareTargets') return () => Promise.resolve([]);
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
/** A client with none of the optional capabilities. */
|
||||
const bareClient = new Proxy({}, {
|
||||
get: (_t, prop) => (prop === 'then' ? undefined : prop === 'getAccountId' ? () => 'acc' : () => null),
|
||||
});
|
||||
|
||||
const skeleton = () => document.querySelector('[data-boot-skeleton="settings"]');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
localStorage.clear();
|
||||
resetRailHintsMemo();
|
||||
localStorage.setItem('settings-active-tab', 'appearance');
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
|
||||
// The restore stays pending: the page is still waiting for the session.
|
||||
useAuthStore.setState({ checkAuth: vi.fn(() => new Promise<void>(() => {})) } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Settings while the session restores', () => {
|
||||
it('draws the settings skeleton when signed out', () => {
|
||||
useAuthStore.setState({ isAuthenticated: false, isLoading: true, client: null });
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
expect(skeleton()).not.toBeNull();
|
||||
expect(skeleton()!.getAttribute('aria-busy')).toBe('true');
|
||||
expect(skeleton()!.querySelector('[role="status"]')!.textContent).toBe('common.loading');
|
||||
});
|
||||
|
||||
it('keeps the skeleton and no nav while the persisted sign-in waits for its client', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: true, client: null });
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
expect(skeleton()).not.toBeNull();
|
||||
expect(screen.queryByTestId('settings-tab-appearance')).toBeNull();
|
||||
expect(screen.queryByText('appearance pane')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the nav once the client is up, in the order the skeleton draws it', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never });
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
expect(skeleton()).toBeNull();
|
||||
const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')]
|
||||
.map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length));
|
||||
// Recovery waits for a server probe this test doesn't answer.
|
||||
const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery');
|
||||
expect(shown).toEqual(drawn);
|
||||
});
|
||||
|
||||
it('leaves the next load the pages this account has', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never });
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
const hints = JSON.parse(localStorage.getItem(BOOT_HINTS_KEY) ?? 'null');
|
||||
expect(hints?.settings).toContain('appearance');
|
||||
expect(hints?.settings).not.toContain('filters');
|
||||
expect(hints?.settings).not.toContain('vacation');
|
||||
});
|
||||
});
|
||||
@@ -6,30 +6,17 @@ import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations, useMessages } from 'next-intl';
|
||||
import {
|
||||
Accessibility,
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
User,
|
||||
Shield,
|
||||
PalmtreeIcon,
|
||||
Calendar,
|
||||
Filter,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
Bell,
|
||||
Puzzle,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
LayoutList,
|
||||
Database,
|
||||
Users,
|
||||
RotateCcw,
|
||||
type AppIcon,
|
||||
} from '@/components/icons';
|
||||
import { SETTINGS_TAB_ICONS, type SettingsTabGroup } from '@/components/settings/settings-tab-icons';
|
||||
import { SettingsSkeleton, SettingsSkeletonBody } from '@/components/boot-skeleton/settings-skeleton';
|
||||
import { useSkeletonLabels } from '@/components/boot-skeleton/use-skeleton-labels';
|
||||
import { writeSettingsHints } from '@/lib/boot-skeleton/hints';
|
||||
import { useRegisterCommands } from '@/lib/command-registry';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
@@ -110,7 +97,7 @@ type Tab = SettingsSearchTab;
|
||||
|
||||
// `top` and `bottom` have no heading: the first five pages, and Backup & reset
|
||||
// after a rule at the end.
|
||||
type TabGroup = 'top' | 'mail' | 'apps' | 'bottom';
|
||||
type TabGroup = SettingsTabGroup;
|
||||
|
||||
// A plugin that exposes a `settings-section` slot gets its own first-class
|
||||
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
|
||||
@@ -131,27 +118,8 @@ interface TabDef {
|
||||
sub?: boolean;
|
||||
}
|
||||
|
||||
const tabIcons: Record<Tab, AppIcon> = {
|
||||
account: User,
|
||||
general: SettingsIcon,
|
||||
notifications: Bell,
|
||||
appearance: Palette,
|
||||
accessibility: Accessibility,
|
||||
message_list: LayoutList,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
organize: FolderOpen,
|
||||
security: Shield,
|
||||
sharing: Users,
|
||||
recovery: RotateCcw,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
addons: Puzzle,
|
||||
backup: Database,
|
||||
};
|
||||
// Shared with the boot skeleton, which draws the same nav before any script runs.
|
||||
const tabIcons = SETTINGS_TAB_ICONS;
|
||||
|
||||
const tabGroupOrder: TabGroup[] = ['top', 'mail', 'apps', 'bottom'];
|
||||
|
||||
@@ -265,6 +233,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
const tShell = useTranslations('shell');
|
||||
const tDelegates = useTranslations('delegates');
|
||||
const tPalette = useTranslations('command_palette');
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
@@ -825,8 +794,24 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
isAuthenticated,
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
// Which pages this account has, for the next load's skeleton nav
|
||||
// (lib/boot-skeleton/hints.ts). Only the account's own list, once its
|
||||
// capabilities are known; plugin entries are left out.
|
||||
const hintedTabs = client && !managedAccountId
|
||||
? visibleTabs.filter((tab) => !tab.id.includes(':')).map((tab) => tab.id).join(' ')
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (hintedTabs) writeSettingsHints(hintedTabs.split(' '));
|
||||
}, [hintedTabs]);
|
||||
|
||||
// Until the session is up, the page's skeleton (the same one the server
|
||||
// drew). Waiting for the client, not only the persisted sign-in, means the
|
||||
// nav renders once with the account's pages instead of growing as the
|
||||
// capabilities arrive.
|
||||
if (!isAuthenticated || !client) {
|
||||
return isEmbedded
|
||||
? <SettingsSkeletonBody embedded />
|
||||
: <SettingsSkeleton label={skeletonLabels.label} labels={skeletonLabels.labels} />;
|
||||
}
|
||||
|
||||
const renderTabContent = () => (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Accessibility,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
User,
|
||||
Shield,
|
||||
PalmtreeIcon,
|
||||
Calendar,
|
||||
Filter,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
Bell,
|
||||
Puzzle,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
LayoutList,
|
||||
Database,
|
||||
Users,
|
||||
RotateCcw,
|
||||
type AppIcon,
|
||||
} from '@/components/icons';
|
||||
import type { SettingsSearchTab } from '@/lib/settings-search';
|
||||
|
||||
/**
|
||||
* The icon of each settings page in the nav. Shared by the settings page and
|
||||
* its boot skeleton (components/boot-skeleton/settings-skeleton.tsx), which
|
||||
* draws the same icons before any script runs.
|
||||
*/
|
||||
export const SETTINGS_TAB_ICONS: Record<SettingsSearchTab, AppIcon> = {
|
||||
account: User,
|
||||
general: SettingsIcon,
|
||||
notifications: Bell,
|
||||
appearance: Palette,
|
||||
accessibility: Accessibility,
|
||||
message_list: LayoutList,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
organize: FolderOpen,
|
||||
security: Shield,
|
||||
sharing: Users,
|
||||
recovery: RotateCcw,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
addons: Puzzle,
|
||||
backup: Database,
|
||||
};
|
||||
|
||||
export type SettingsTabGroup = 'top' | 'mail' | 'apps' | 'bottom';
|
||||
|
||||
/**
|
||||
* The settings nav in order, by group, with every page a server can offer.
|
||||
* The settings page leaves out what the account or the admin doesn't have
|
||||
* (see the `tabs` list in settings-app.tsx); the skeleton hides those rows
|
||||
* from the last session's hints.
|
||||
*/
|
||||
export const SETTINGS_NAV_GROUPS: readonly { group: SettingsTabGroup; tabs: readonly SettingsSearchTab[] }[] = [
|
||||
{ group: 'top', tabs: ['account', 'security', 'sharing', 'notifications', 'general', 'appearance', 'accessibility'] },
|
||||
{ group: 'mail', tabs: ['message_list', 'reading', 'composing', 'organize', 'filters', 'vacation', 'recovery'] },
|
||||
{ group: 'apps', tabs: ['calendar', 'contacts', 'files', 'addons'] },
|
||||
{ group: 'bottom', tabs: ['backup'] },
|
||||
];
|
||||
@@ -406,6 +406,17 @@ describe('boot script: rail hints', () => {
|
||||
boot('/en/mail');
|
||||
expect(attr('rail')).toBe('mail calendar quota');
|
||||
});
|
||||
|
||||
it('passes the settings pages the nav listed on, known ids only', () => {
|
||||
localStorage.setItem('bulwark-boot-hints', JSON.stringify({ v: 1, rail: ['mail'], settings: ['account', 'filters', 'recovery', 'plugin:x', 'nope', 7] }));
|
||||
boot('/en/settings');
|
||||
expect(attr('settings-tabs')).toBe('account filters recovery');
|
||||
});
|
||||
|
||||
it('leaves the settings pages out without hints', () => {
|
||||
boot('/en/settings');
|
||||
expect(attr('settings-tabs')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boot script: Lite', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TARGET_NAMES, getTarget } from '@/lib/connector/registry';
|
||||
import { locales } from '@/i18n/routing';
|
||||
import { CONNECTOR_SURFACES, surfaceFromPath } from '../surfaces';
|
||||
import { BOOT_HINTS_KEY } from '../keys';
|
||||
import { resetRailHintsMemo, writeRailHints } from '../hints';
|
||||
import { resetRailHintsMemo, writeRailHints, writeSettingsHints } from '../hints';
|
||||
|
||||
describe('connector targets', () => {
|
||||
it('every target the connector can resolve has a skeleton surface', () => {
|
||||
@@ -75,4 +75,12 @@ describe('rail hints', () => {
|
||||
writeRailHints(['files', 'admin']);
|
||||
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!).rail).toEqual(['mail', 'files', 'admin']);
|
||||
});
|
||||
|
||||
it('keeps the rail and the settings pages side by side', () => {
|
||||
writeRailHints(['calendar']);
|
||||
writeSettingsHints(['account', 'filters']);
|
||||
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar'], settings: ['account', 'filters'] });
|
||||
writeRailHints(['files']);
|
||||
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'files'], settings: ['account', 'filters'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,6 +253,12 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
|
||||
? (hints.rail as unknown[]).filter(function (r) { return typeof r === 'string' && /^[a-z-]+$/.test(r); })
|
||||
: null;
|
||||
mark('rail', rail && rail.length ? rail.join(' ') : null);
|
||||
// The settings pages the nav listed: the skeleton hides the optional ones
|
||||
// that weren't there. Pages only, from the known list.
|
||||
var settingsTabs = hints && Array.isArray(hints.settings)
|
||||
? (hints.settings as unknown[]).filter(function (t) { return has(c.tabs, t); })
|
||||
: null;
|
||||
mark('settings-tabs', settingsTabs && settingsTabs.length ? settingsTabs.join(' ') : null);
|
||||
|
||||
// --- Probably signed out? --------------------------------------------
|
||||
// No redirect here: a cookie-only session (impersonation, a server hand-off)
|
||||
|
||||
@@ -10,18 +10,38 @@ export type RailHint = 'calendar' | 'contacts' | 'files' | 'add-app' | 'admin' |
|
||||
|
||||
let lastWritten: string | null = null;
|
||||
|
||||
export function writeRailHints(items: RailHint[]): void {
|
||||
const rail = ['mail', ...items];
|
||||
const value = JSON.stringify({ v: 1, rail });
|
||||
/** Replaces one part of the stored hints and keeps the others. */
|
||||
function writeHints(patch: { rail?: string[]; settings?: string[] }): void {
|
||||
let stored: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(BOOT_HINTS_KEY) || 'null');
|
||||
if (parsed && typeof parsed === 'object' && parsed.v === 1) stored = parsed;
|
||||
} catch {
|
||||
/* corrupt or blocked: start over */
|
||||
}
|
||||
const value = JSON.stringify({ ...stored, ...patch, v: 1 });
|
||||
if (value === lastWritten) return;
|
||||
lastWritten = value;
|
||||
try {
|
||||
localStorage.setItem(BOOT_HINTS_KEY, value);
|
||||
} catch {
|
||||
/* storage full or blocked: the next skeleton draws every app */
|
||||
/* storage full or blocked: the next skeleton draws its defaults */
|
||||
}
|
||||
}
|
||||
|
||||
export function writeRailHints(items: RailHint[]): void {
|
||||
writeHints({ rail: ['mail', ...items] });
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages the settings nav listed for this account, which depend on its
|
||||
* capabilities and the admin's policy. The boot script turns it into
|
||||
* `data-boot-settings-tabs`.
|
||||
*/
|
||||
export function writeSettingsHints(tabs: string[]): void {
|
||||
writeHints({ settings: tabs });
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetRailHintsMemo(): void {
|
||||
lastWritten = null;
|
||||
|
||||
Reference in New Issue
Block a user