feat(settings): confirmations in the shared dialog, token colours on About, scope picker in the new look

This commit is contained in:
Maurice
2026-10-01 16:19:41 +02:00
parent 56dfd56bab
commit cf3818cb66
17 changed files with 337 additions and 191 deletions
@@ -80,69 +80,54 @@ describe('AboutTab', () => {
expect(screen.queryByText('v2.9.10')).toBeNull();
});
it('FE-COMP-ABOUT-012: Ko-fi link hover changes border and box-shadow styles', () => {
// The hover look lives in Tailwind classes driven by the tile's `--tile`
// colour now, so the tests pin the colour each tile hands its classes.
const tile = (link: HTMLAnchorElement) => ({
color: link.style.getPropertyValue('--tile'),
hovers: link.className.includes('hover:border-[color:var(--tile)]'),
});
it('FE-COMP-ABOUT-012: the Ko-fi tile carries its brand colour for the hover border', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = screen.getByText('Ko-fi').closest('a') as HTMLAnchorElement;
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(255, 94, 91)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
expect(tile(link)).toEqual({ color: '#ff5e5b', hovers: true });
});
it('FE-COMP-ABOUT-013: Buy Me a Coffee link hover changes border and box-shadow styles', () => {
it('FE-COMP-ABOUT-013: the Buy Me a Coffee tile carries its brand colour for the hover border', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = screen.getByText('Buy Me a Coffee').closest('a') as HTMLAnchorElement;
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(255, 221, 0)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
expect(tile(link)).toEqual({ color: '#ffdd00', hovers: true });
});
it('FE-COMP-ABOUT-014: Discord link hover changes border and box-shadow styles', () => {
it('FE-COMP-ABOUT-014: the Discord tile carries its brand colour for the hover border', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = screen.getByText('Discord').closest('a') as HTMLAnchorElement;
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(88, 101, 242)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
expect(tile(link)).toEqual({ color: '#5865F2', hovers: true });
});
it('FE-COMP-ABOUT-015: Bug report link hover changes border and box-shadow styles', () => {
it('FE-COMP-ABOUT-015: the bug report tile uses the danger token', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = document.querySelector('a[href*="issues/new"]') as HTMLAnchorElement;
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(239, 68, 68)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link).toHaveAttribute('target', '_blank');
expect(tile(link)).toEqual({ color: 'var(--danger)', hovers: true });
});
it('FE-COMP-ABOUT-016: Feature request link hover changes border and box-shadow styles', () => {
it('FE-COMP-ABOUT-016: the feature request tile uses the warning token', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = document.querySelector('a[href*="discussions/new"]') as HTMLAnchorElement;
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(245, 158, 11)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link).toHaveAttribute('target', '_blank');
expect(tile(link)).toEqual({ color: 'var(--warning)', hovers: true });
});
it('FE-COMP-ABOUT-017: Wiki link hover changes border and box-shadow styles', () => {
it('FE-COMP-ABOUT-017: the wiki tile uses the info token and no inline hover styles are left', () => {
render(<AboutTab appVersion="1.0.0" />);
const link = document.querySelector('a[href*="wiki"]') as HTMLAnchorElement;
expect(tile(link)).toEqual({ color: 'var(--info)', hovers: true });
fireEvent.mouseEnter(link);
expect(link.style.borderColor).toBe('rgb(99, 102, 241)');
expect(link.style.boxShadow).not.toBe('');
fireEvent.mouseLeave(link);
expect(link.style.borderColor).toBe('var(--border-primary)');
expect(link.style.boxShadow).toBe('none');
expect(link.style.borderColor).toBe('');
expect(link.style.boxShadow).toBe('');
});
});
+28 -31
View File
@@ -14,49 +14,46 @@ const DISCORD_PATH = 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0
// The services' own colours: each tile takes its brand on hover and in its icon square.
const BRAND = {
kofi: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
bmac: '#ffdd00', // theme-lint-disable: Buy Me a Coffee brand colour
discord: '#5865F2', // theme-lint-disable: Discord brand colour
bug: '#ef4444', // theme-lint-disable: the bug tile's signal red, a fixed brand-like mark
idea: '#f59e0b', // theme-lint-disable: the idea tile's signal amber, a fixed brand-like mark
wiki: '#6366f1', // theme-lint-disable: the wiki tile's signal indigo, a fixed brand-like mark
kofi: '#ff5e5b', // theme-lint-disable: brand colour (Ko-fi)
bmac: '#ffdd00', // theme-lint-disable: brand colour (Buy Me a Coffee)
discord: '#5865F2', // theme-lint-disable: brand colour (Discord)
} as const;
// TREK's own tiles speak in the theme's signal colours instead.
const SIGNAL = {
bug: 'var(--danger)',
idea: 'var(--warning)',
wiki: 'var(--info)',
} as const;
/** Tile chrome that reads the tile's colour from `--tile`: the border and a faint ring on hover. */
const TILE_HOVER = 'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]';
/** The icon square in the tile's colour. */
const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[color:var(--tile)]';
/**
* One outbound link as a tile: a tinted icon square, a title and a hint, the
* external-link mark on the right. The border picks up the brand colour while
* external-link mark on the right. The border picks up the tile's colour while
* the pointer is on it.
*/
function LinkTile({ href, brand, icon, title, hint }: {
function LinkTile({ href, color, icon, title, hint }: {
href: string;
brand?: string;
/** A brand colour or a theme token, handed to the classes as `--tile`. */
color?: string;
icon?: ReactNode;
title: ReactNode;
hint: ReactNode;
}) {
const tint: CSSProperties | undefined = brand
? { background: `color-mix(in srgb, ${brand} 12%, transparent)`, color: brand }
: undefined;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={brand ? (e) => {
e.currentTarget.style.borderColor = brand;
e.currentTarget.style.boxShadow = `0 0 0 1px ${brand}22`;
} : undefined}
onMouseLeave={brand ? (e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
} : undefined}
className={`flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] ${color ? TILE_HOVER : ''}`}
style={color ? ({ '--tile': color } as CSSProperties) : undefined}
>
{icon && (
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${brand ? '' : 'bg-surface-tertiary text-content-secondary'}`}
style={tint}
>
<span className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${color ? TILE_ICON : 'bg-surface-tertiary text-content-secondary'}`}>
{icon}
</span>
)}
@@ -112,21 +109,21 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<LinkTile
href="https://ko-fi.com/mauriceboe"
brand={BRAND.kofi}
color={BRAND.kofi}
icon={<Coffee size={19} />}
title="Ko-fi"
hint={t('admin.github.support')}
/>
<LinkTile
href="https://buymeacoffee.com/mauriceboe"
brand={BRAND.bmac}
color={BRAND.bmac}
icon={<Heart size={19} />}
title="Buy Me a Coffee"
hint={t('admin.github.support')}
/>
<LinkTile
href="https://discord.gg/NhZBDSd4qW"
brand={BRAND.discord}
color={BRAND.discord}
icon={(
<svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={DISCORD_PATH} />
@@ -137,21 +134,21 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
/>
<LinkTile
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
brand={BRAND.bug}
color={SIGNAL.bug}
icon={<Bug size={19} />}
title={t('settings.about.reportBug')}
hint={t('settings.about.reportBugHint')}
/>
<LinkTile
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
brand={BRAND.idea}
color={SIGNAL.idea}
icon={<Lightbulb size={19} />}
title={t('settings.about.featureRequest')}
hint={t('settings.about.featureRequestHint')}
/>
<LinkTile
href="https://github.com/liketrek/TREK/wiki"
brand={BRAND.wiki}
color={SIGNAL.wiki}
icon={<BookOpen size={19} />}
title="Wiki"
hint={t('settings.about.wikiHint')}
@@ -512,23 +512,24 @@ describe('OfflineTab', () => {
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
});
it('FE-COMP-OFFLINETAB-023: Clear cache asks first and a declined confirm keeps the data', async () => {
it('FE-COMP-OFFLINETAB-023: Clear cache asks first and a cancelled confirm keeps the data', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
cache([{ trip: paris }]);
render(<OfflineTab />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
expect(confirmSpy).toHaveBeenCalledWith('Clear all offline trip data? You can re-sync anytime while online.');
expect(await screen.findByText('Clear all offline trip data? You can re-sync anytime while online.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Clear all offline trip data? You can re-sync anytime while online.')).not.toBeInTheDocument();
expect(h.clearAll).not.toHaveBeenCalled();
expect(within(card('Offline cache')).getByText('Paris')).toBeInTheDocument();
});
it('FE-COMP-OFFLINETAB-024: confirming Clear cache wipes the database and reloads', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
cache([{ trip: paris }]);
render(<OfflineTab />);
@@ -536,6 +537,11 @@ describe('OfflineTab', () => {
h.syncMetaToArray.mockResolvedValue([]);
h.tripsCount.mockResolvedValue(0);
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
await screen.findByText('Clear all offline trip data? You can re-sync anytime while online.');
expect(h.clearAll).not.toHaveBeenCalled();
// The dialog's own confirm button is the last "Clear cache" in the document.
const confirmButtons = screen.getAllByRole('button', { name: 'Clear cache' });
await user.click(confirmButtons[confirmButtons.length - 1]!);
expect(h.clearAll).toHaveBeenCalledTimes(1);
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
+14 -3
View File
@@ -10,7 +10,7 @@
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
* `MSettingsOffline`; this file is the desktop markup over it.
*/
import React from 'react'
import React, { useState } from 'react'
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
@@ -18,6 +18,7 @@ import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SET
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { fs } from '../shared/DialogShell'
import type { ConflictStrategy } from '../../sync/offlinePrefs'
import type { QueuedMutation } from '../../db/offlineDb'
@@ -42,6 +43,7 @@ export default function OfflineTab(): React.ReactElement {
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
handleConflictStrategy,
} = useOfflineSettings()
const [confirmClear, setConfirmClear] = useState(false)
const formatDate = (d: string | null | undefined) =>
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
@@ -207,8 +209,7 @@ export default function OfflineTab(): React.ReactElement {
icon={Database}
action={
<button type="button"
// ConfirmDialog would be the planner's way; the tab's tests pin window.confirm.
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
onClick={() => setConfirmClear(true)}
disabled={clearing || !canClear}
className={SETTINGS_BUTTON_DANGER}
style={fs(12.5, 'body')}
@@ -257,6 +258,16 @@ export default function OfflineTab(): React.ReactElement {
</SettingRows>
)}
</Section>
<ConfirmDialog
isOpen={confirmClear}
onClose={() => setConfirmClear(false)}
onConfirm={() => { void handleClear() }}
title={t('settings.offline.clear')}
message={t('settings.offline.clearConfirm')}
confirmLabel={t('settings.offline.clear')}
danger
/>
</div>
)
}
@@ -326,9 +326,8 @@ describe('PluginSettingsTab', () => {
expect(button).toBeEnabled();
});
it('FE-COMP-PLUGINSETTINGS-018: a dangerous action asks first and a declined confirm skips it', async () => {
it('FE-COMP-PLUGINSETTINGS-018: a dangerous action asks first and a cancelled confirm skips it', async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
let ran = 0;
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
@@ -339,14 +338,18 @@ describe('PluginSettingsTab', () => {
render(<PluginSettingsTab />);
await user.click(await screen.findByRole('button', { name: 'Wipe cache' }));
expect(confirmSpy).toHaveBeenCalledWith('Run this action?');
expect(await screen.findByText('Run this action?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Run this action?')).not.toBeInTheDocument();
expect(ran).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByRole('button', { name: 'Wipe cache' }));
await screen.findByText('Run this action?');
// The dialog's confirm button carries the action's label and sits last in the document.
const confirmButtons = screen.getAllByRole('button', { name: 'Wipe cache' });
await user.click(confirmButtons[confirmButtons.length - 1]!);
await screen.findByText('Wiped');
expect(ran).toBe(1);
confirmSpy.mockRestore();
});
it('FE-COMP-PLUGINSETTINGS-019: an unconfigured OAuth integration renders no connect row', async () => {
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useState, type ReactNode } from 'react'
import { Save, Loader2, Link2, Unlink, CheckCircle, Puzzle, Zap } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { SettingRow, SettingRows, SettingsCard, SettingsHint, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
@@ -107,6 +108,8 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
const [actions, setActions] = useState<PluginAction[]>([])
const [running, setRunning] = useState<string | null>(null)
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
// A dangerous action waits here for the user's answer in the confirm dialog.
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
useEffect(() => {
let alive = true
@@ -128,8 +131,12 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
// An action runs AS the caller, so it sees the values they just saved — run the save
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
const runAction = async (a: PluginAction) => {
if (a.danger && !window.confirm(t('settings.plugins.actions.confirm'))) return
const runAction = (a: PluginAction) => {
if (a.danger) { setPendingAction(a); return }
void performAction(a)
}
const performAction = async (a: PluginAction) => {
setRunning(a.key)
try {
const res = await pluginsApi.runAction(id, a.key)
@@ -259,6 +266,14 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
</div>
)}
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
<ConfirmDialog
isOpen={pendingAction !== null}
onClose={() => setPendingAction(null)}
onConfirm={() => { if (pendingAction) void performAction(pendingAction) }}
message={t('settings.plugins.actions.confirm')}
confirmLabel={pendingAction?.label}
danger
/>
</SettingsCard>
)
}