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}