From dc1ac0ae85e3d5d8a067a4894e35b07e0253fe7c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 19 Sep 2026 12:04:18 +0200 Subject: [PATCH] fix(docsync): give the sync flow its own TREK icon and restore the wordmark The document-sync flow needed TREK's square app icon next to the provider glyphs, and the PR got it by rewriting the shared TrekMark component. TrekMark is the wordmark with the API pill that the admin Places API card, the mobile admin block and the release notes popup draw, so all three showed the app icon instead, and AdminSettingsTab FE-ADMSET-031 failed on the missing "TREK Places API" image. TrekMark is back to its dev version. The app icon now lives in TrekIcon, which DocSyncFlow and MDocSyncSheet use. AddonManager had also switched Synology Photos to the Synology Drive glyph, which renders as a block and sat off-line from the Immich row under Journey. Photos keeps its own icon again. The new Documents shelf gets tests: its rows and glyphs, staying out of the addon count, hiding while Documents is off, the re-read after Documents is switched off, and a provider toggle that saves without re-reading. --- .../components/Admin/AddonManager.test.tsx | 99 ++++++++++++++++++- client/src/components/Admin/AddonManager.tsx | 13 ++- .../components/Files/docsync/DocSyncFlow.tsx | 4 +- .../shared/DocumentProviderIcons.test.tsx | 22 ++--- client/src/components/shared/TrekIcon.tsx | 19 ++++ client/src/components/shared/TrekMark.tsx | 26 ++--- .../screens/trip/tabs/MDocSyncSheet.tsx | 4 +- 7 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 client/src/components/shared/TrekIcon.tsx diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index ad5f1ccb0..0964cedc0 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-036 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -418,7 +418,102 @@ describe('AddonManager', () => { expect(isOn(addonToggle('Todo List'))).toBe(true); }); - it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }), + buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Paperless-ngx'); + const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement; + expect(paperlessRow.querySelector('svg')).toBeInTheDocument(); + expect(isOn(subToggle('Paperless-ngx'))).toBe(true); + expect(isOn(subToggle('Nextcloud'))).toBe(false); + // Documents and Journey are the addons; an enabled provider is a shelf row + // and must not turn 1/2 into 2/2. + expect(screen.getByText('1/2')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), + ])); + render(); + + await screen.findByText('Documents'); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let documentsOn = true; + let paperlessOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }), + ], + }); + }), + http.put('/api/admin/addons/documents', async ({ request }) => { + documentsOn = (await request.json() as { enabled: boolean }).enabled; + if (!documentsOn) paperlessOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(addonToggle('Documents')); + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + + await user.click(addonToggle('Documents')); + + await screen.findByText('Paperless-ngx'); + expect(isOn(subToggle('Paperless-ngx'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => { + const user = userEvent.setup(); + let loads = 0; + let body: unknown = null; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }), + ], + }); + }), + http.put('/api/admin/addons/papra', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Papra'); + + await user.click(subToggle('Papra')); + + await screen.findByText('Addon updated'); + expect(body).toEqual({ enabled: true }); + expect(loads).toBe(1); + expect(isOn(subToggle('Papra'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 5623c1ba0..6002d42c7 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -9,7 +9,7 @@ import CustomSelect from '../shared/CustomSelect' import EmptyState from '../shared/EmptyState' import DawarichIcon from '../shared/DawarichIcon' import AirTrailIcon from '../shared/AirTrailIcon' -import { DOCUMENT_PROVIDER_ICONS, SynologyDriveIcon } from '../shared/DocumentProviderIcons' +import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' @@ -31,10 +31,17 @@ function ImmichIcon({ size = 14 }: { size?: number }) { ) } +function SynologyIcon({ size = 14 }: { size?: number }) { + return ( + + + + ) +} + const PROVIDER_ICONS: Record> = { immich: ImmichIcon, - // Photos and Drive are the same brand, so they carry the same mark. - synologyphotos: SynologyDriveIcon, + synologyphotos: SynologyIcon, // The document providers' own marks, single-colour so they read as glyphs in // a row of lucide icons and invert with the theme. ...DOCUMENT_PROVIDER_ICONS, diff --git a/client/src/components/Files/docsync/DocSyncFlow.tsx b/client/src/components/Files/docsync/DocSyncFlow.tsx index 6ec3f4c85..f09969b6d 100644 --- a/client/src/components/Files/docsync/DocSyncFlow.tsx +++ b/client/src/components/Files/docsync/DocSyncFlow.tsx @@ -1,7 +1,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react' import { useTranslation } from '../../../i18n/TranslationContext' import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' -import TrekMark from '../../shared/TrekMark' +import TrekIcon from '../../shared/TrekIcon' import { Badge } from './DocSyncBits' export type SyncDirection = 'both' | 'pull' | 'push' @@ -70,7 +70,7 @@ export default function DocSyncFlow({ } + glyph={} />
diff --git a/client/src/components/shared/DocumentProviderIcons.test.tsx b/client/src/components/shared/DocumentProviderIcons.test.tsx index b3ffc558e..c5ee09395 100644 --- a/client/src/components/shared/DocumentProviderIcons.test.tsx +++ b/client/src/components/shared/DocumentProviderIcons.test.tsx @@ -7,14 +7,14 @@ * person would notice if they slipped: a provider the server seeds but the * client has no glyph for (the row renders nothing at all), a mark that keeps a * brand colour and so stays black on a dark background, and the sizing contract - * the callers rely on — `size` for the bare lucide-sized rows, a `className` + * the callers rely on: `size` for the bare lucide-sized rows, a `className` * everywhere the surrounding Tailwind box decides how big the glyph is. */ import { describe, it, expect } from 'vitest' import { DOCUMENT_PROVIDER_IDS } from '@trek/shared' import { render } from '../../../tests/helpers/render' import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons' -import TrekMark from './TrekMark' +import TrekIcon from './TrekIcon' const marks = Object.entries(DOCUMENT_PROVIDER_ICONS) @@ -76,7 +76,7 @@ describe('DOCUMENT_PROVIDER_ICONS', () => { } }) - it('FE-DOCSYNC-ICONS-006: stays out of the accessibility tree — the provider name is next to it', () => { + it('FE-DOCSYNC-ICONS-006: stays out of the accessibility tree, the provider name is next to it', () => { for (const [id, Icon] of marks) { expect(renderMark(Icon).getAttribute('aria-hidden'), id).toBe('true') } @@ -117,9 +117,9 @@ describe('DOCUMENT_PROVIDER_ICONS', () => { }) }) -describe('TrekMark', () => { - it('FE-DOCSYNC-ICONS-011: renders TREK\u2019s mark in currentColor', () => { - const { container } = render() +describe('TrekIcon', () => { + it('FE-DOCSYNC-ICONS-011: renders TREK\u2019s app icon in currentColor', () => { + const { container } = render() const svg = container.querySelector('svg') expect(svg).not.toBeNull() @@ -128,7 +128,7 @@ describe('TrekMark', () => { }) it('FE-DOCSYNC-ICONS-012: takes a className, which is the only thing that sizes it', () => { - const { container } = render() + const { container } = render() const svg = container.querySelector('svg')! expect(svg.getAttribute('class')).toBe('h-6 w-6 text-content') @@ -137,18 +137,18 @@ describe('TrekMark', () => { }) it('FE-DOCSYNC-ICONS-013: forwards the rest of its svg props', () => { - const { container } = render() + const { container } = render() const svg = container.querySelector('svg')! - expect(svg.getAttribute('data-testid')).toBe('trek-mark') + expect(svg.getAttribute('data-testid')).toBe('trek-icon') expect(svg.getAttribute('role')).toBe('img') }) it('FE-DOCSYNC-ICONS-014: is decorative by default and can be unhidden by the caller', () => { - const { container } = render() + const { container } = render() expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true') - const labelled = render() + const labelled = render() const svg = labelled.container.querySelector('svg')! expect(svg.getAttribute('aria-hidden')).toBe('false') expect(svg.getAttribute('aria-label')).toBe('TREK') diff --git a/client/src/components/shared/TrekIcon.tsx b/client/src/components/shared/TrekIcon.tsx new file mode 100644 index 000000000..e10775f68 --- /dev/null +++ b/client/src/components/shared/TrekIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' + +/** + * TREK's square app icon, from public/icons/icon.svg. The wordmark with the API + * pill is `TrekMark`. + * + * Inlined rather than loaded as an so it takes `currentColor` and follows + * the theme like the provider glyphs it sits next to in the document-sync flow. + * The path is the icon file's, unchanged. + */ +export default function TrekIcon({ className, ...props }: SVGProps) { + return ( + + + + + + ) +} diff --git a/client/src/components/shared/TrekMark.tsx b/client/src/components/shared/TrekMark.tsx index aff863077..807582809 100644 --- a/client/src/components/shared/TrekMark.tsx +++ b/client/src/components/shared/TrekMark.tsx @@ -1,19 +1,21 @@ -import type { SVGProps } from 'react' +import React from 'react' /** - * TREK's own mark, from public/icons/icon.svg. + * The TREK wordmark, including the pill. * - * Inlined rather than loaded as an so it takes `currentColor`: the - * document-sync flow puts it next to provider glyphs that all follow the theme, - * and a fixed-colour file would be the one thing in the row that ignores dark - * mode. The path is the icon file's, unchanged. + * `fill="currentColor"` on the mark and the two pill classes below are what let + * one file serve both themes: the letters take the surrounding text colour, the + * pill inverts against it. No second asset, no dark-mode variant to keep in + * sync, and it stays correct if the accent or the surface ever changes. */ -export default function TrekMark({ className, ...props }: SVGProps) { +export default function TrekMark(props: React.SVGProps): React.ReactElement { return ( - - - - - + <> + + + ) } diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx index 23aff8dbc..05a66e40b 100644 --- a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx @@ -10,7 +10,7 @@ import MConfirmSheet from '../../settings/MConfirmSheet' import { TileHeader } from '../sheets/MTripSheetUi' import { useTranslation } from '../../../../i18n' import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons' -import TrekMark from '../../../../components/shared/TrekMark' +import TrekIcon from '../../../../components/shared/TrekIcon' import { useDocSync, type DocSyncLink, type DocSyncProvider, } from '../../../../components/Files/docsync/useDocSync' @@ -265,7 +265,7 @@ function DetailView({ a phone does not have. */}
- } count={holdings.inTrek} name={t('docsync.flow.trek')} /> + } count={holdings.inTrek} name={t('docsync.flow.trek')} /> : null} count={holdings.atProvider}