From 1430056d24e768370cfcef698497d255cb600230 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 18 Sep 2026 22:44:45 +0200 Subject: [PATCH] fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attempt counter and the backoff were written on every failure and never consulted when planning the next run: rows were found in a live database at 46 attempts against a limit of 6, so a document a provider refuses — one it reads as corrupt, a type it will not take, a full quota — was re-uploaded on every cron tick forever. planReconcile now skips a row that is shelved or still inside its window, and a manual run clears the counters, which is what "waits for a person" has to mean. The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed, with a comment explaining it, and nothing read it. Providers fire once per document, so twenty files dropped into a watched folder were twenty runs — the in-flight guard answered `busy` to nineteen of them, throwing away nineteen announcements, and the one run that did start had begun before most of the changes landed. A burst now folds into one trailing run, and the binding is re-read when the timer fires so a sync switched off in between does not get a last run out of a stale row. Four defects the new suites turned up, all fixed: the sync button lost its accessible name below sm, where its label is hidden; the stored address was never shown in the connect dialog although the form kept submitting it; the allow-self-signed switch only read the connection at mount, so a stored "on" showed as off and the next save wrote that back; and a refused folder create escaped as an unhandled rejection with nothing said to anybody. Also here: the phone shell gets the same feature (one sheet, four views, the same hook as the desktop), the marks come from the product's own icon files rather than redrawn approximations, and the 22 non-English locales carry the new keys. ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the test takes its example from MCP_GATED_ADDON_IDS instead of naming one. --- client/src/api/wsEventPolicy.ts | 4 + client/src/components/Admin/AddonManager.tsx | 19 +- client/src/components/Files/FileManager.tsx | 18 +- .../Files/docsync/DocSyncBinding.test.tsx | 348 +++++++++ .../Files/docsync/DocSyncBinding.tsx | 214 ++++++ .../components/Files/docsync/DocSyncBits.tsx | 85 +++ .../docsync/DocSyncConnectModal.test.tsx | 494 ++++++++++++ .../Files/docsync/DocSyncConnectModal.tsx | 197 +++++ .../Files/docsync/DocSyncFlow.test.tsx | 214 ++++++ .../components/Files/docsync/DocSyncFlow.tsx | 213 ++++++ .../Files/docsync/DocSyncPanel.test.tsx | 370 +++++++++ .../components/Files/docsync/DocSyncPanel.tsx | 625 ++++++++------- .../Files/docsync/DocSyncScopeModal.test.tsx | 367 +++++++++ .../Files/docsync/DocSyncScopeModal.tsx | 175 +++++ .../Files/docsync/useDocSync.test.ts | 389 ++++++++++ .../components/Files/docsync/useDocSync.ts | 85 ++- client/src/components/Files/useFileManager.ts | 12 +- .../components/Packing/useBagTotalsPing.ts | 89 +-- .../shared/DocumentProviderIcons.test.tsx | 156 ++++ .../shared/DocumentProviderIcons.tsx | 137 ++++ client/src/components/shared/TrekMark.tsx | 26 +- client/src/index.css | 46 ++ client/src/mobile/screens/trip/MTripShell.tsx | 8 +- .../screens/trip/tabs/MDocSyncSheet.test.tsx | 350 +++++++++ .../screens/trip/tabs/MDocSyncSheet.tsx | 719 ++++++++++++++++++ .../mobile/screens/trip/tabs/MFilesTab.tsx | 21 + client/src/sync/useServerPing.ts | 93 +++ client/tests/helpers/mobileTrip.ts | 1 + .../nest/doc-sync/doc-sync-config.service.ts | 14 +- .../doc-sync/doc-sync-webhook.controller.ts | 49 +- .../src/nest/doc-sync/doc-sync.constants.ts | 6 +- .../src/nest/doc-sync/doc-sync.controller.ts | 3 + server/src/nest/doc-sync/doc-sync.helpers.ts | 76 +- server/src/nest/doc-sync/doc-sync.service.ts | 65 +- .../providers/synology-drive.provider.ts | 5 +- server/tests/unit/nest/admin.service.test.ts | 15 +- .../doc-sync-webhook.controller.test.ts | 102 ++- .../nest/doc-sync/doc-sync.controller.test.ts | 26 + .../nest/doc-sync/doc-sync.helpers.test.ts | 88 +++ shared/src/i18n/ar/docsync.ts | 86 ++- shared/src/i18n/br/docsync.ts | 89 ++- shared/src/i18n/ca/docsync.ts | 91 ++- shared/src/i18n/cs/docsync.ts | 92 ++- shared/src/i18n/de/docsync.ts | 91 ++- shared/src/i18n/en/docsync.ts | 89 ++- shared/src/i18n/es/docsync.ts | 91 ++- shared/src/i18n/fr/docsync.ts | 93 ++- shared/src/i18n/gr/docsync.ts | 92 ++- shared/src/i18n/hu/docsync.ts | 91 ++- shared/src/i18n/id/docsync.ts | 91 ++- shared/src/i18n/it/docsync.ts | 91 ++- shared/src/i18n/ja/docsync.ts | 87 ++- shared/src/i18n/ko/docsync.ts | 86 ++- shared/src/i18n/nl/docsync.ts | 90 ++- shared/src/i18n/pl/docsync.ts | 90 ++- shared/src/i18n/ru/docsync.ts | 90 ++- shared/src/i18n/sv/docsync.ts | 88 ++- shared/src/i18n/tr/docsync.ts | 95 ++- shared/src/i18n/uk/docsync.ts | 93 ++- shared/src/i18n/vi/docsync.ts | 90 ++- shared/src/i18n/zh-TW/docsync.ts | 84 +- shared/src/i18n/zh/docsync.ts | 83 +- 62 files changed, 7263 insertions(+), 724 deletions(-) create mode 100644 client/src/components/Files/docsync/DocSyncBinding.test.tsx create mode 100644 client/src/components/Files/docsync/DocSyncBinding.tsx create mode 100644 client/src/components/Files/docsync/DocSyncBits.tsx create mode 100644 client/src/components/Files/docsync/DocSyncConnectModal.test.tsx create mode 100644 client/src/components/Files/docsync/DocSyncConnectModal.tsx create mode 100644 client/src/components/Files/docsync/DocSyncFlow.test.tsx create mode 100644 client/src/components/Files/docsync/DocSyncFlow.tsx create mode 100644 client/src/components/Files/docsync/DocSyncPanel.test.tsx create mode 100644 client/src/components/Files/docsync/DocSyncScopeModal.test.tsx create mode 100644 client/src/components/Files/docsync/DocSyncScopeModal.tsx create mode 100644 client/src/components/Files/docsync/useDocSync.test.ts create mode 100644 client/src/components/shared/DocumentProviderIcons.test.tsx create mode 100644 client/src/components/shared/DocumentProviderIcons.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx create mode 100644 client/src/sync/useServerPing.ts diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 268083586..64e628fec 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -81,6 +81,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ // who has the book open, and where their pointers are) 'journey:book:peers', 'journey:book:cursor', + // Document sync — components/Files/docsync/useDocSync re-reads the bindings + // and their counts. Content-free like the bag ping: what a run changed is + // per-binding server state, and the files it moved arrive as file:* events. + 'docsync:changed', // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) 'import:progress', 'import:done', diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 6b1c8548b..5623c1ba0 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -9,6 +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 AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' @@ -30,21 +31,13 @@ function ImmichIcon({ size = 14 }: { size?: number }) { ) } -function SynologyIcon({ size = 14 }: { size?: number }) { - return ( - - - - ) -} - const PROVIDER_ICONS: Record> = { immich: ImmichIcon, - synologyphotos: SynologyIcon, - // Document providers reuse the Synology mark for the NAS and fall back to a - // generic lucide icon otherwise, rather than shipping four more brand SVGs - // for rows that are 14px tall. - synologydrive: SynologyIcon, + // Photos and Drive are the same brand, so they carry the same mark. + synologyphotos: SynologyDriveIcon, + // 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, } interface Addon { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 352f96d73..7e103cbfa 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -13,7 +13,7 @@ export default function FileManager(props: FileManagerProps) { const S = useFileManager(props) const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S return ( -
+
{/* Lightbox */} {lightboxIndex !== null && setLightboxIndex(null)} />} @@ -25,13 +25,17 @@ export default function FileManager(props: FileManagerProps) { ? : )} - {/* Document sync, as an overlay rather than a route: it is configuration - for the documents on this screen, and sending someone to settings to - reach it loses the context that makes the folder choice obvious. */} + {/* Document sync opens as its own dialog: it is configuration for the + documents on this screen, so it belongs here rather than in settings, + but it is a task with a beginning and an end and does not belong + inside the list it configures. */} {S.showDocSync && ( -
- S.setShowDocSync(false)} /> -
+ S.setShowDocSync(false)} + /> )} {/* Toolbar */} diff --git a/client/src/components/Files/docsync/DocSyncBinding.test.tsx b/client/src/components/Files/docsync/DocSyncBinding.test.tsx new file mode 100644 index 000000000..c1bcbee73 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncBinding.test.tsx @@ -0,0 +1,348 @@ +// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-025 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render' +import DocSyncBinding from './DocSyncBinding' +import type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync' + +type Sync = ReturnType + +const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })) +const updateLink = vi.fn(async (_linkId: number, _patch: Record) => {}) +const removeLink = vi.fn(async (_linkId: number) => {}) + +const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123' + +const provider: DocSyncProvider = { + id: 'paperless', + name: 'Paperless', + description: null, + icon: 'paperless', + available: true, + fields: [], +} + +function makeLink(overrides: Partial = {}): DocSyncLink { + return { + id: 4, + connectionId: 1, + providerId: 'paperless', + scopeKey: 'trek-trip-3', + remoteLabel: 'Lisbon papers', + remoteRootPath: '/TREK/Lisbon', + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + lastSyncAt: null, + lastSyncState: 'ok', + lastSyncError: null, + webhookUrl: null, + holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 }, + ...overrides, + } +} + +function makeSync(overrides: Partial = {}): Sync { + return { + providers: [], + connections: [], + links: [], + itemCounts: {}, + loading: false, + busy: null, + error: null, + connectionFor: () => null, + load: vi.fn(), + saveConnection: vi.fn(), + testConnection: vi.fn(), + loadScopes: vi.fn(), + createScope: vi.fn(), + createLink: vi.fn(), + updateLink, + removeLink, + syncNow, + resolveConflict: vi.fn(), + lastRunFor: () => ({ pulled: 0, pushed: 0 }), + ...overrides, + } as unknown as Sync +} + +function renderCard( + opts: { + link?: Partial + sync?: Partial + isOwner?: boolean + /** null means "the provider list has not arrived yet". */ + provider?: DocSyncProvider | null + } = {}, +) { + const utils = render( + , + ) + const header = utils.container.querySelector('header') as HTMLElement + return { ...utils, header } +} + +const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + +const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('DocSyncBinding — running a sync', () => { + it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => { + renderCard() + + fireEvent.click(screen.getByRole('button', { name: 'Sync now' })) + + expect(syncNow).toHaveBeenCalledWith(4) + }) + + it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => { + renderCard({ sync: { busy: 'sync-4' } }) + + const button = screen.getByRole('button', { name: 'Syncing' }) + expect(button).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument() + + fireEvent.click(button) + expect(syncNow).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => { + renderCard({ sync: { busy: 'sync-9' } }) + + expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled() + }) + + it('FE-DOCSYNC-BIND-004: the disconnect button unlinks this binding', () => { + renderCard() + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(removeLink).toHaveBeenCalledWith(4) + }) +}) + +describe('DocSyncBinding — what it says about itself', () => { + it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => { + renderCard() + + expect(screen.queryByText('In sync')).not.toBeInTheDocument() + const dot = screen.getByTitle('In sync') + expect(dot).toBeInTheDocument() + expect(dot).toBeEmptyDOMElement() + }) + + it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => { + renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } }) + + expect(screen.getByText('Failed')).toBeInTheDocument() + expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => { + renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } }) + + // once as the header badge, once as the banner underneath it + expect(screen.getAllByText('Failed')).toHaveLength(2) + }) + + it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => { + renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } }) + + expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => { + renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } }) + + expect(screen.getAllByText('Not synced yet')).toHaveLength(1) + expect(screen.getByText('not run yet')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => { + const { unmount } = renderCard({ link: { syncEnabled: false } }) + expect(screen.getByText('Paused')).toBeInTheDocument() + unmount() + + renderCard({ link: { syncEnabled: true } }) + expect(screen.queryByText('Paused')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => { + renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } }) + + expect(screen.getByText(/hours ago/)).toBeInTheDocument() + expect(screen.queryByText('not run yet')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => { + const { unmount } = renderCard({ link: { remoteLabel: '' } }) + expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument() + unmount() + + renderCard({ link: { remoteLabel: '', remoteRootPath: null } }) + expect(screen.getByText('trek-trip-3')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-013: without a provider record the card falls back to the provider id', () => { + const { header } = renderCard({ provider: null }) + + expect(within(header).getByText('paperless')).toBeInTheDocument() + expect(within(header).queryByText('Paperless')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => { + const { unmount } = renderCard() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + unmount() + + renderCard({ link: { holdings: undefined } }) + expect(screen.getAllByText('0')).toHaveLength(2) + }) +}) + +describe('DocSyncBinding — the settings section', () => { + it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => { + renderCard() + + const toggle = screen.getByRole('button', { name: 'Settings' }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument() + expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument() + + fireEvent.click(toggle) + + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Sync automatically')).toBeInTheDocument() + expect(screen.getByText('When a document is deleted')).toBeInTheDocument() + + fireEvent.click(toggle) + expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => { + renderCard({ isOwner: false }) + + expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument() + expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument() + expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument() + // a member may still trigger a run + expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled() + }) + + it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => { + renderCard() + openSettings() + + fireEvent.click(within(rowFor('Sync automatically')).getByRole('button')) + + expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false }) + }) + + it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => { + renderCard({ link: { syncEnabled: false } }) + openSettings() + + const toggle = within(rowFor('Sync automatically')).getByRole('button') + expect(toggle).toHaveAttribute('aria-pressed', 'false') + fireEvent.click(toggle) + + expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true }) + }) + + it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => { + renderCard() + openSettings() + + const trigger = within(rowFor('When a document is deleted')).getByRole('button') + expect(trigger).toHaveTextContent('Keep both copies') + + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' })) + + expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' }) + }) +}) + +describe('DocSyncBinding — the webhook URL', () => { + const realClipboard = navigator.clipboard + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true }) + }) + + const stubClipboard = (writeText: (text: string) => Promise) => { + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true }) + } + + it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => { + renderCard({ link: { webhookUrl: null } }) + openSettings() + + expect(screen.queryByText('Instant updates')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => { + renderCard({ link: { webhookUrl: WEBHOOK } }) + openSettings() + + expect(screen.getByText('Instant updates')).toBeInTheDocument() + expect(screen.getByText(WEBHOOK)).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => { + const writeText = vi.fn(async (_text: string) => {}) + stubClipboard(writeText) + renderCard({ link: { webhookUrl: WEBHOOK } }) + openSettings() + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK)) + expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument() + }) + + it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => { + const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') }) + stubClipboard(writeText) + renderCard({ link: { webhookUrl: WEBHOOK } }) + openSettings() + + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + + await waitFor(() => expect(writeText).toHaveBeenCalled()) + expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument() + expect(screen.getByText(WEBHOOK)).toBeInTheDocument() + }) +}) + +describe('DocSyncBinding — the direction lanes', () => { + it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => { + renderCard() + + fireEvent.click(screen.getByRole('button', { name: 'Out to the store' })) + + expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' }) + }) + + it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => { + renderCard({ isOwner: false }) + + const lane = screen.getByRole('button', { name: 'Out to the store' }) + expect(lane).toBeDisabled() + fireEvent.click(lane) + expect(updateLink).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncBinding.tsx b/client/src/components/Files/docsync/DocSyncBinding.tsx new file mode 100644 index 000000000..2776e60c5 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncBinding.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react' +import { + AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw, +} from 'lucide-react' +import CustomSelect from '../../shared/CustomSelect' +import ToggleSwitch from '../../Settings/ToggleSwitch' +import Tooltip from '../../shared/Tooltip' +import { useTranslation } from '../../../i18n/TranslationContext' +import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' +import DocSyncFlow, { type SyncDirection } from './DocSyncFlow' +import { Badge, LastRun, StateBadge } from './DocSyncBits' +import type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync' + +/** + * One binding: where this trip's documents live, which way they move, and what + * happened last time. + * + * The flow bar carries the direction because that is the one thing this feature + * is about. Everything a person sets once and forgets — the delete rule, the + * webhook URL — is folded away behind "Settings" so the card stays a status + * card rather than a form. + */ +export default function DocSyncBinding({ + link, + provider, + sync, + isOwner, +}: { + link: DocSyncLink + provider?: DocSyncProvider + sync: ReturnType + isOwner: boolean +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId] + const busy = sync.busy === `sync-${link.id}` + const needsAttention = link.lastSyncState !== 'ok' && link.lastSyncState !== 'never' + + const copyWebhook = async () => { + if (!link.webhookUrl) return + try { + await navigator.clipboard.writeText(link.webhookUrl) + setCopied(true) + setTimeout(() => setCopied(false), 1600) + } catch { + // Clipboard is blocked outside a secure context; the URL is selectable. + } + } + + return ( +
+
+ + {Icon ? : null} + + + + + + {provider?.name || link.providerId} + + {/* A healthy binding needs no word for it — the dot is the whole + message, and the states that do need words get them below. */} + + {!link.syncEnabled && {t('docsync.binding.autoOff')}} + + + {/* Where and when, as two facts rather than a sentence — they are what + a member checks before asking why a file has not turned up. */} + + } title={link.remoteRootPath || undefined}> + + {link.remoteLabel || link.remoteRootPath || link.scopeKey} + + + }> + + + + + + {/* Both controls are h-9 so the square one cannot sit a pixel off the + other — the two together read as one segmented control. */} + + + {isOwner && ( + + + + )} + +
+ + {needsAttention && ( +

+ + + {t(`docsync.linkState.${link.lastSyncState}`)} + {link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''} + +

+ )} + +
+ void sync.updateLink(link.id, { direction: next })} + /> +
+ + {isOwner && ( + <> + + + {open && ( +
+ + void sync.updateLink(link.id, { deletePolicy: String(v) })} + options={[ + { value: 'unlink', label: t('docsync.deleteUnlink') }, + { value: 'trash', label: t('docsync.deleteTrash') }, + ]} + /> + + + + + void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })} + /> + + + + {/* Only where TREK could not subscribe itself. Papra's webhook CRUD + is closed to API keys and Nextcloud's needs admin rights, so the + person pastes this in by hand; polling carries it either way. */} + {link.webhookUrl && ( +
+ {/* Same tier as the two rows above it — this is the third + setting, not a footnote to the second. */} + {t('docsync.binding.webhookTitle')} +

{t('docsync.webhookHint')}

+
+ + {link.webhookUrl} + + +
+
+ )} +
+ )} + + )} +
+ ) +} + +function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {label} + {hint && {hint}} + + {children} +
+ ) +} diff --git a/client/src/components/Files/docsync/DocSyncBits.tsx b/client/src/components/Files/docsync/DocSyncBits.tsx new file mode 100644 index 000000000..c0935a7c5 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncBits.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react' +import { useTranslation } from '../../../i18n/TranslationContext' +import { relativeTime } from '../../../utils/relativeTime' + +/** + * The small shared pieces of the document-sync dialog. + * + * They live together because the panel, the store list and the binding card all + * label the same five sync states, and three hand-written copies of that mapping + * is how one of them ends up saying something different from the other two. + */ + +export type Tone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' + +const TONES: Record = { + neutral: 'bg-surface-secondary text-content-secondary', + accent: 'bg-accent-subtle text-accent-on', + success: 'bg-success-soft text-success', + warning: 'bg-warning-soft text-warning', + danger: 'bg-danger-soft text-danger', +} + +/** A count or a short label, on a tinted pill. */ +export function Badge({ + tone = 'neutral', + icon, + children, + title, +}: { + tone?: Tone + icon?: ReactNode + children: ReactNode + title?: string +}) { + return ( + + {icon} + {children} + + ) +} + +/** The tone each sync state carries, in one place. */ +function toneForState(state: string): Tone { + if (state === 'ok') return 'success' + if (state === 'partial') return 'warning' + if (state === 'never') return 'neutral' + return 'danger' +} + +/** + * A binding's state as a dot plus its word. + * + * A dot alone is a colour puzzle and a word alone is easy to skim past; the two + * together survive both colour blindness and a glance. + */ +export function StateBadge({ state, compact }: { state: string; compact?: boolean }) { + const { t } = useTranslation() + const tone = toneForState(state) + const dot = + tone === 'success' ? 'bg-success' + : tone === 'warning' ? 'bg-warning' + : tone === 'danger' ? 'bg-danger' + : 'bg-content-faint' + + if (compact) return + + return ( + }> + {t(`docsync.linkState.${state}`)} + + ) +} + +/** "2h ago", or "never" when a binding has not run yet. */ +export function LastRun({ at }: { at: string | null }) { + const { t, language, locale } = useTranslation() + if (!at) return {t('docsync.binding.neverRun')} + const ms = Date.parse(at) + if (Number.isNaN(ms)) return null + return {relativeTime(ms, language)} +} diff --git a/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx b/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx new file mode 100644 index 000000000..bad8645e8 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx @@ -0,0 +1,494 @@ +// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import en from '@trek/shared/i18n/en' +import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render' +import type { DocSyncConnection, DocSyncProvider } from './useDocSync' + +/** What the app really shows: `en` is bundled, so `t()` resolves for real here. */ +const tx = (key: string): string => (en as unknown as Record)[key] ?? key + +const providers = vi.fn(async (_tripId: number | string): Promise => []) +const listConnections = vi.fn(async (_tripId: number | string): Promise => []) +const listLinks = vi.fn(async (_tripId: number | string): Promise => []) +const status = vi.fn(async (_tripId: number | string): Promise => ({ items: {}, links: [] })) +const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ id: 1 })) +const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ connected: true })) + +vi.mock('../../../api/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + docsyncApi: { + ...actual.docsyncApi, + providers: (tripId: number | string) => providers(tripId), + status: (tripId: number | string) => status(tripId), + listConnections: (tripId: number | string) => listConnections(tripId), + listLinks: (tripId: number | string) => listLinks(tripId), + saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data), + testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data), + }, + } +}) + +import DocSyncConnectModal from './DocSyncConnectModal' +import { useDocSync } from './useDocSync' + +const TRIP_ID = 42 + +const nextcloud: DocSyncProvider = { + id: 'nextcloud', + name: 'Nextcloud', + description: 'Two-way document sync with a Nextcloud folder over WebDAV', + icon: 'Cloud', + available: true, + fields: [ + { field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: true, secret: false }, + { field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: true, secret: false }, + { field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: true, secret: true }, + { field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: false, secret: false }, + { field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false }, + ], +} + +/** Synology is the one provider with a secret that is NOT a password box (the OTP). */ +const synology: DocSyncProvider = { + id: 'synologydrive', + name: 'Synology Drive', + description: 'Two-way document sync with a folder on a Synology NAS', + icon: 'HardDrive', + available: true, + fields: [ + { field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: true, secret: false }, + { field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: true, secret: true }, + { field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: false, secret: true }, + { field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false }, + ], +} + +const connection = (overrides: Partial = {}): DocSyncConnection => ({ + id: 9, + providerId: 'nextcloud', + baseUrl: 'https://cloud.example.com', + settings: { login_name: 'maurice', base_path: '/Archive/TREK' }, + secrets: { app_password: 'stored' }, + allowInsecureTls: false, + lastProbeState: 'ok', + lastProbeError: null, + ...overrides, +}) + +function Harness({ + provider, + onClose, + onConnected, + waitForConnection, +}: { + provider: DocSyncProvider + onClose: () => void + onConnected: (providerId: string) => void + /** + * Hold the dialog back until the stored connection is in hand, the way the + * panel does: it only offers "connect" once it knows what is already there. + */ + waitForConnection?: boolean +}) { + const sync = useDocSync(TRIP_ID, true) + if (waitForConnection && !sync.connectionFor(provider.id)) return null + return +} + +/** Let the hook's initial load land before anything is asserted. */ +async function flush() { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) +} + +async function open(provider: DocSyncProvider = nextcloud, waitForConnection = false) { + const onClose = vi.fn() + const onConnected = vi.fn() + const utils = render( + , + ) + await flush() + return { ...utils, onClose, onConnected } +} + +const testButton = () => screen.getByRole('button', { name: tx('docsync.test') }) +const connectButton = () => screen.getByRole('button', { name: tx('docsync.connect.submit') }) + +function fillNextcloud() { + fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } }) + fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } }) + fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } }) +} + +beforeEach(() => { + vi.clearAllMocks() + providers.mockResolvedValue([]) + listConnections.mockResolvedValue([]) + listLinks.mockResolvedValue([]) + status.mockResolvedValue({ items: {}, links: [] }) + saveConnection.mockResolvedValue({ id: 1 }) + testConnection.mockResolvedValue({ connected: true }) +}) + +describe('DocSyncConnectModal form', () => { + it('FE-DOCSYNC-CONNECT-001: renders one labelled box per provider field and leaves the TLS field to the switch', async () => { + const { baseElement } = await open() + + expect(screen.getByText(tx('docsync.providerUrl'))).toBeInTheDocument() + expect(screen.getByText(tx('docsync.providerUsername'))).toBeInTheDocument() + expect(screen.getByText(tx('docsync.providerAppPassword'))).toBeInTheDocument() + expect(screen.getByText(tx('docsync.providerBasePath'))).toBeInTheDocument() + // allow_insecure_tls is a switch, not a fifth text box + expect(baseElement.querySelectorAll('input')).toHaveLength(4) + expect(screen.getByText(tx('docsync.allowInsecureTls'))).toBeInTheDocument() + expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-002: a password field is masked and the other boxes are not', async () => { + await open() + + expect(screen.getByPlaceholderText('app password')).toHaveAttribute('type', 'password') + expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveAttribute('type', 'text') + expect(screen.getByPlaceholderText('username')).toHaveAttribute('type', 'text') + expect(screen.getByPlaceholderText('/TREK')).toHaveAttribute('type', 'text') + }) + + it('FE-DOCSYNC-CONNECT-003: a secret the provider declares as plain text stays readable', async () => { + await open(synology) + + expect(screen.getByPlaceholderText('password')).toHaveAttribute('type', 'password') + expect(screen.getByPlaceholderText('123456')).toHaveAttribute('type', 'text') + }) + + it('FE-DOCSYNC-CONNECT-004: only the optional fields carry the optional mark', async () => { + await open() + const optional = tx('docsync.connect.optional') + + const basePath = screen.getByPlaceholderText('/TREK').closest('label') as HTMLElement + expect(within(basePath).getByText(optional)).toBeInTheDocument() + + for (const placeholder of ['https://cloud.example.com', 'username', 'app password']) { + const field = screen.getByPlaceholderText(placeholder).closest('label') as HTMLElement + expect(within(field).queryByText(optional)).not.toBeInTheDocument() + } + expect(screen.getAllByText(optional)).toHaveLength(1) + }) + + it('FE-DOCSYNC-CONNECT-005: a field hint is translated, and a field without one gets no line', async () => { + await open() + + const username = screen.getByPlaceholderText('username').closest('label') as HTMLElement + expect(within(username).getByText(tx('docsync.hintNextcloudLogin'))).toBeInTheDocument() + + const baseUrl = screen.getByPlaceholderText('https://cloud.example.com').closest('label') as HTMLElement + expect(baseUrl.textContent?.trim()).toBe(tx('docsync.providerUrl')) + }) + + it('FE-DOCSYNC-CONNECT-006: the dialog is headed by the provider and explains what it will do', async () => { + await open() + + expect(screen.getByRole('heading', { name: nextcloud.name })).toBeInTheDocument() + expect(screen.getByText(tx('docsync.connect.about.nextcloud'))).toBeInTheDocument() + expect(screen.getByText(tx('docsync.connect.insecureHint'))).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-007: escape closes the dialog without saving', async () => { + const { onClose, onConnected } = await open() + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(onClose).toHaveBeenCalled() + expect(onConnected).not.toHaveBeenCalled() + expect(saveConnection).not.toHaveBeenCalled() + }) +}) + +describe('DocSyncConnectModal required fields', () => { + it('FE-DOCSYNC-CONNECT-008: nothing can be saved or probed while a required field is empty', async () => { + await open() + + expect(connectButton()).toBeDisabled() + expect(testButton()).toBeDisabled() + + fireEvent.click(connectButton()) + fireEvent.click(testButton()) + + expect(saveConnection).not.toHaveBeenCalled() + expect(testConnection).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-CONNECT-009: a missing password alone is enough to refuse the save', async () => { + await open() + + fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } }) + fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } }) + + expect(connectButton()).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } }) + + expect(connectButton()).toBeEnabled() + }) + + it('FE-DOCSYNC-CONNECT-010: an address of nothing but spaces does not count as filled', async () => { + await open() + + fillNextcloud() + expect(connectButton()).toBeEnabled() + + fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: ' ' } }) + + expect(connectButton()).toBeDisabled() + expect(testButton()).toBeDisabled() + }) + + it('FE-DOCSYNC-CONNECT-011: an empty optional field does not hold the save back', async () => { + const { onConnected } = await open() + + fillNextcloud() + fireEvent.click(connectButton()) + + await waitFor(() => expect(onConnected).toHaveBeenCalled()) + expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, { + providerId: 'nextcloud', + baseUrl: 'https://cloud.example.com', + credentials: { base_url: 'https://cloud.example.com', login_name: 'maurice', app_password: 'app-pw' }, + allowInsecureTls: false, + }) + }) +}) + +describe('DocSyncConnectModal probe verdict', () => { + it('FE-DOCSYNC-CONNECT-012: the probe carries the typed address, credentials and TLS choice', async () => { + await open() + + fillNextcloud() + fireEvent.change(screen.getByPlaceholderText('/TREK'), { target: { value: '/Archive' } }) + fireEvent.click(testButton()) + + await waitFor(() => expect(testConnection).toHaveBeenCalledWith(TRIP_ID, { + providerId: 'nextcloud', + baseUrl: 'https://cloud.example.com', + credentials: { + base_url: 'https://cloud.example.com', + login_name: 'maurice', + app_password: 'app-pw', + base_path: '/Archive', + }, + allowInsecureTls: false, + })) + expect(saveConnection).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-CONNECT-013: a reachable provider names the account it signed in as', async () => { + testConnection.mockResolvedValueOnce({ connected: true, account: 'maurice' }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + const line = tx('docsync.connect.okAs').replace('{account}', 'maurice') + expect(await screen.findByText(line)).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-014: a reachable provider without an account just reports the connection', async () => { + testConnection.mockResolvedValueOnce({ connected: true }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + expect(await screen.findByText(tx('docsync.connected'))).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-015: a refused credential shows the translated reason', async () => { + testConnection.mockResolvedValueOnce({ connected: false, error: 'unauthorized' }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + const line = await screen.findByText(tx('docsync.error.unauthorized')) + expect(line).toBeInTheDocument() + expect(screen.queryByText(tx('docsync.connected'))).not.toBeInTheDocument() + expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).not.toMatch(/shield/) + }) + + it('FE-DOCSYNC-CONNECT-016: an address TREK itself blocked is marked with the shield, not the warning', async () => { + testConnection.mockResolvedValueOnce({ connected: false, error: 'ssrf_blocked' }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + const line = await screen.findByText(tx('docsync.error.ssrf_blocked')) + expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).toMatch(/shield/) + }) + + it('FE-DOCSYNC-CONNECT-017: a failure with no reason falls back to the unknown message', async () => { + testConnection.mockResolvedValueOnce({ connected: false }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + expect(await screen.findByText(tx('docsync.error.unknown'))).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-018: a request that throws still lands as a verdict rather than an empty footer', async () => { + testConnection.mockRejectedValueOnce({ response: { data: { error: 'timeout' } } }) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + expect(await screen.findByText(tx('docsync.error.timeout'))).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-019: while the probe runs the dialog says so and the probe button is locked', async () => { + let settle: (value: unknown) => void = () => {} + testConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve })) + await open() + + fillNextcloud() + fireEvent.click(testButton()) + + expect(screen.getByText(tx('docsync.connect.testing'))).toBeInTheDocument() + expect(testButton()).toBeDisabled() + + await act(async () => { + settle({ connected: true }) + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(screen.queryByText(tx('docsync.connect.testing'))).not.toBeInTheDocument() + expect(screen.getByText(tx('docsync.connected'))).toBeInTheDocument() + expect(testButton()).toBeEnabled() + }) +}) + +describe('DocSyncConnectModal saving', () => { + it('FE-DOCSYNC-CONNECT-020: a successful save reports the connected provider upward', async () => { + const { onConnected, onClose } = await open() + + fillNextcloud() + fireEvent.click(connectButton()) + + await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud')) + expect(onConnected).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + // the freshly stored connection is read back, not assumed + expect(listConnections).toHaveBeenCalledTimes(2) + }) + + it('FE-DOCSYNC-CONNECT-021: a rejected save reports nothing upward and leaves the form standing', async () => { + saveConnection.mockRejectedValueOnce({ response: { data: { error: 'unauthorized' } } }) + const { onConnected, onClose } = await open() + + fillNextcloud() + fireEvent.click(connectButton()) + + await waitFor(() => expect(saveConnection).toHaveBeenCalled()) + expect(onConnected).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByPlaceholderText('app password')).toHaveValue('app-pw') + }) + + it('FE-DOCSYNC-CONNECT-022: the save button is locked while the write is in flight', async () => { + let settle: (value: unknown) => void = () => {} + saveConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve })) + const { onConnected } = await open() + + fillNextcloud() + fireEvent.click(connectButton()) + + expect(connectButton()).toBeDisabled() + + await act(async () => { + settle({ id: 1 }) + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(onConnected).toHaveBeenCalledWith('nextcloud') + }) + + it('FE-DOCSYNC-CONNECT-023: the self-signed switch starts off and travels with the request', async () => { + await open() + + fillNextcloud() + fireEvent.click(screen.getByRole('button', { pressed: false })) + + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument() + + fireEvent.click(connectButton()) + + await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ allowInsecureTls: true }))) + }) +}) + +describe('DocSyncConnectModal with a connection already stored', () => { + it('FE-DOCSYNC-CONNECT-024: the stored settings come back, the saved secret is only hinted at, and it saves untouched', async () => { + listConnections.mockResolvedValue([connection({ settings: { login_name: 'maurice', base_path: '/Archive/TREK', app_password: 'never-echo-me' } })]) + const { onConnected } = await open(nextcloud, true) + + expect(await screen.findByPlaceholderText('••••••••')).toBeInTheDocument() + expect(screen.getByPlaceholderText('username')).toHaveValue('maurice') + expect(screen.getByPlaceholderText('/TREK')).toHaveValue('/Archive/TREK') + // a secret is never echoed back into the box, whatever the settings blob holds + expect(screen.getByPlaceholderText('••••••••')).toHaveValue('') + + // the stored secret counts as filled, so nothing has to be retyped + expect(connectButton()).toBeEnabled() + fireEvent.click(connectButton()) + + await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud')) + expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, { + providerId: 'nextcloud', + baseUrl: 'https://cloud.example.com', + credentials: {}, + allowInsecureTls: false, + }) + }) + + it('FE-DOCSYNC-CONNECT-025: the stored self-signed choice is the one the switch starts on', async () => { + listConnections.mockResolvedValue([connection({ allowInsecureTls: true })]) + await open(nextcloud, true) + + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-026: a connection that lands after the dialog is open still sets the switch', async () => { + // The switch used to be seeded by a useState initialiser, so it only ever + // read the connection that existed at the moment the dialog mounted: a + // stored "allow self-signed" showed as off, and the next save wrote that + // back. A security switch turning itself off unasked is the bad version of + // a stale field, so it now reads through until somebody touches it. + listConnections.mockResolvedValue([connection({ allowInsecureTls: true })]) + await open(nextcloud, false) + + await screen.findByPlaceholderText('••••••••') + await waitFor(() => expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()) + }) + + it('FE-DOCSYNC-CONNECT-027: the stored address is shown as well as submitted', async () => { + listConnections.mockResolvedValue([connection()]) + const { onConnected } = await open(nextcloud, true) + + await screen.findByPlaceholderText('••••••••') + // The address is a column of its own rather than a settings entry. Reading + // it out of settings left the box empty over its placeholder while the form + // went on submitting the stored value — the field and the request quietly + // disagreeing about what the connection is. + await waitFor(() => + expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveValue('https://cloud.example.com'), + ) + + fireEvent.click(connectButton()) + + await waitFor(() => expect(onConnected).toHaveBeenCalled()) + expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' })) + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncConnectModal.tsx b/client/src/components/Files/docsync/DocSyncConnectModal.tsx new file mode 100644 index 000000000..e5cd07836 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncConnectModal.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react' +import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react' +import Modal from '../../shared/Modal' +import ToggleSwitch from '../../Settings/ToggleSwitch' +import { useTranslation } from '../../../i18n/TranslationContext' +import { Badge } from './DocSyncBits' +import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' +import type { DocSyncProvider, useDocSync } from './useDocSync' + +/** + * Connecting a store, as a dialog rather than a panel that unfolds in place. + * + * Entering a URL and a token is a task with a beginning and an end, and doing it + * inside the list meant the list jumped around while someone typed. The dialog + * also gives the probe result somewhere to live that is not a line of text + * squeezed between two fields. + */ +export default function DocSyncConnectModal({ + provider, + sync, + onClose, + onConnected, +}: { + provider: DocSyncProvider + sync: ReturnType + onClose: () => void + onConnected: (providerId: string) => void +}) { + const { t } = useTranslation() + const existing = sync.connectionFor(provider.id) + const Icon = DOCUMENT_PROVIDER_ICONS[provider.id] + + const [values, setValues] = useState>({}) + const [insecure, setInsecure] = useState(null) + const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) + + // Null until touched, so a connection that loads after the dialog opened is + // still reflected. Seeding the state at mount showed a stored "allow + // self-signed" as off and wrote that back on the next save — a security + // switch silently turning itself off is the worst version of this bug. + const insecureOn = insecure ?? existing?.allowInsecureTls ?? false + + const baseUrl = values.base_url ?? existing?.baseUrl ?? '' + // The address is a column of its own, not a settings entry, so it has to be + // read from there — otherwise the field sits empty while the form submits the + // stored value behind it. + const shown = (key: string) => + values[key] ?? (key === 'base_url' ? existing?.baseUrl : existing?.settings?.[key]) ?? '' + const required = provider.fields.filter(f => f.required) + const canSubmit = required.every(f => + f.field_key === 'base_url' + ? baseUrl.trim().length > 0 + : f.secret + ? (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key] + : shown(f.field_key).trim().length > 0, + ) + + const test = async () => setVerdict(await sync.testConnection(provider.id, baseUrl, values, insecureOn)) + + const save = async () => { + if (await sync.saveConnection(provider.id, baseUrl, values, insecureOn)) onConnected(provider.id) + } + + return ( + + {Icon && } + {provider.name} + + } + footer={ +
+ +
+ + +
+
+ } + > +
+

{t(`docsync.connect.about.${provider.id}`)}

+ + {provider.fields.filter(f => f.field_key !== 'allow_insecure_tls').map(f => ( + + setValues({ ...values, [f.field_key]: e.target.value })} + className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2" + /> + + ))} + + +
+ + ) +} + +function Field({ + label, + hint, + required, + children, +}: { + label: string + hint?: string + required?: boolean + children: React.ReactNode +}) { + const { t } = useTranslation() + return ( + + ) +} + +/** The probe result, in the footer where it stays put while the form scrolls. */ +function Verdict({ + verdict, + busy, +}: { + verdict: { connected: boolean; account?: string; error?: string } | null + busy: boolean +}) { + const { t } = useTranslation() + if (busy) { + return ( + + + {t('docsync.connect.testing')} + + ) + } + if (!verdict) return + if (verdict.connected) { + return ( + + + + {verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')} + + + ) + } + const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted' + return ( + + {blocked ? : } + {t(`docsync.error.${verdict.error || 'unknown'}`)} + + ) +} diff --git a/client/src/components/Files/docsync/DocSyncFlow.test.tsx b/client/src/components/Files/docsync/DocSyncFlow.test.tsx new file mode 100644 index 000000000..655567e64 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncFlow.test.tsx @@ -0,0 +1,214 @@ +// FE-DOCSYNC-FLOW-001 to FE-DOCSYNC-FLOW-013 + +/** + * The direction control of the document-sync dialog. + * + * Two lanes, each one a direction that is either carrying or idle. The rules + * that matter to somebody using it: what each side is holding is readable + * before a word is, switching direction means switching a lane off, and the + * last lane cannot be switched off, a binding with no direction syncs + * nothing, and a dialog that lets you build one has lied about what it does. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import en from '@trek/shared/i18n/en' +import { render, screen, fireEvent } from '../../../../tests/helpers/render' +import DocSyncFlow, { type FlowHoldings, type SyncDirection } from './DocSyncFlow' + +// The captions come from the locale rather than from English typed out here: +// the assertion is "the lane labelled as the outbound one", not the wording. +const S = en as unknown as Record +const OUT = S['docsync.flow.toProvider'] +const IN = S['docsync.flow.toTrek'] + +const onChange = vi.fn() + +const holdings: FlowHoldings = { inTrek: 12, atProvider: 7, paired: 5, missing: 2 } + +function renderFlow( + props: Partial<{ + direction: SyncDirection + providerId: string + providerName: string + holdings: FlowHoldings + running: boolean + disabled: boolean + }> = {}, +) { + return render( + , + ) +} + +const laneOut = () => screen.getByRole('button', { name: OUT }) +const laneIn = () => screen.getByRole('button', { name: IN }) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('DocSyncFlow ends', () => { + it('FE-DOCSYNC-FLOW-001: shows what each side is holding, next to the name of that side', () => { + renderFlow() + + expect(screen.getByText('12')).toBeInTheDocument() + expect(screen.getByText('7')).toBeInTheDocument() + expect(screen.getByText(S['docsync.flow.trek'])).toBeInTheDocument() + expect(screen.getByText('Paperless')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-FLOW-002: an empty side reads as zero rather than as nothing', () => { + // A healthy binding transfers nothing; the panel must still answer the + // question somebody opened it for. + renderFlow({ holdings: { inTrek: 0, atProvider: 0, paired: 0, missing: 0 } }) + + expect(screen.getAllByText('0')).toHaveLength(2) + }) + + it('FE-DOCSYNC-FLOW-003: an unrecognised store still names itself', () => { + renderFlow({ providerId: 'not-a-provider', providerName: 'Some Store' }) + + expect(screen.getByText('Some Store')).toBeInTheDocument() + expect(laneOut()).toBeInTheDocument() + }) +}) + +describe('DocSyncFlow direction switching', () => { + it('FE-DOCSYNC-FLOW-004: switching the outbound lane off from both leaves inbound only', () => { + renderFlow({ direction: 'both' }) + + fireEvent.click(laneOut()) + + expect(onChange).toHaveBeenCalledWith('pull') + }) + + it('FE-DOCSYNC-FLOW-005: switching the inbound lane off from both leaves outbound only', () => { + renderFlow({ direction: 'both' }) + + fireEvent.click(laneIn()) + + expect(onChange).toHaveBeenCalledWith('push') + }) + + it('FE-DOCSYNC-FLOW-006: switching the idle inbound lane on from push gives both ways', () => { + renderFlow({ direction: 'push' }) + + fireEvent.click(laneIn()) + + expect(onChange).toHaveBeenCalledWith('both') + }) + + it('FE-DOCSYNC-FLOW-007: switching the idle outbound lane on from pull gives both ways', () => { + renderFlow({ direction: 'pull' }) + + fireEvent.click(laneOut()) + + expect(onChange).toHaveBeenCalledWith('both') + }) + + it('FE-DOCSYNC-FLOW-008: the last carrying lane refuses to switch off (push)', () => { + renderFlow({ direction: 'push' }) + + fireEvent.click(laneOut()) + + expect(onChange).not.toHaveBeenCalled() + expect(laneOut()).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-DOCSYNC-FLOW-009: the last carrying lane refuses to switch off (pull)', () => { + renderFlow({ direction: 'pull' }) + + fireEvent.click(laneIn()) + + expect(onChange).not.toHaveBeenCalled() + expect(laneIn()).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-DOCSYNC-FLOW-010: a member who may not edit the binding cannot move the lanes', () => { + renderFlow({ direction: 'both', disabled: true }) + + expect(laneOut()).toBeDisabled() + expect(laneIn()).toBeDisabled() + + fireEvent.click(laneOut()) + fireEvent.click(laneIn()) + + expect(onChange).not.toHaveBeenCalled() + }) +}) + +describe('DocSyncFlow lane state', () => { + it('FE-DOCSYNC-FLOW-011: aria-pressed mirrors each lane in every direction', () => { + const { rerender } = renderFlow({ direction: 'both' }) + expect(laneOut()).toHaveAttribute('aria-pressed', 'true') + expect(laneIn()).toHaveAttribute('aria-pressed', 'true') + + rerender( + , + ) + expect(laneOut()).toHaveAttribute('aria-pressed', 'true') + expect(laneIn()).toHaveAttribute('aria-pressed', 'false') + + rerender( + , + ) + expect(laneOut()).toHaveAttribute('aria-pressed', 'false') + expect(laneIn()).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-DOCSYNC-FLOW-012: only the switched-off lane is hatched', () => { + // The hatch is the whole of "this road is not driven on", there is no + // other handle for it, so the class is the behaviour here. + const { rerender } = renderFlow({ direction: 'both' }) + expect(laneOut()).not.toHaveClass('trek-docsync-lane-off') + expect(laneIn()).not.toHaveClass('trek-docsync-lane-off') + + rerender( + , + ) + expect(laneOut()).not.toHaveClass('trek-docsync-lane-off') + expect(laneIn()).toHaveClass('trek-docsync-lane-off') + }) + + it('FE-DOCSYNC-FLOW-013: the hint tells an editor the lanes are tappable, a reader only what happens', () => { + const { rerender } = renderFlow({ direction: 'both' }) + expect(screen.getByText(S['docsync.flow.summaryEditable.both'])).toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText(S['docsync.flow.summary.both'])).toBeInTheDocument() + expect(screen.queryByText(S['docsync.flow.summaryEditable.both'])).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncFlow.tsx b/client/src/components/Files/docsync/DocSyncFlow.tsx new file mode 100644 index 000000000..6ec3f4c85 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncFlow.tsx @@ -0,0 +1,213 @@ +import { ArrowLeft, ArrowRight } from 'lucide-react' +import { useTranslation } from '../../../i18n/TranslationContext' +import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' +import TrekMark from '../../shared/TrekMark' +import { Badge } from './DocSyncBits' + +export type SyncDirection = 'both' | 'pull' | 'push' + +export interface FlowHoldings { + /** Documents this trip has in TREK. */ + inTrek: number + /** How many of them the store is holding. */ + atProvider: number + paired: number + missing: number +} + +/** + * The direction control, drawn as what it actually is. + * + * A document sync has one characteristic picture — two places and things moving + * between them — and a dropdown reading "Both ways" hides it behind a word. + * Here the two ends carry what each side is holding, the lanes between them + * carry the direction, and switching direction means switching a lane off. It + * is the one loud element in the dialog; everything around it stays quiet. + * + * The numbers are standing counts, not the last run's transfers: a binding that + * is already in step transferred nothing, and a panel answering "0" on a + * healthy connection says nothing at the moment somebody looks at it. + */ +export default function DocSyncFlow({ + direction, + providerId, + providerName, + holdings, + running, + disabled, + onChange, +}: { + direction: SyncDirection + providerId: string + providerName: string + holdings: FlowHoldings + /** A run is in flight: the active lanes move while it is. */ + running?: boolean + disabled?: boolean + onChange: (next: SyncDirection) => void +}) { + const { t } = useTranslation() + const Icon = DOCUMENT_PROVIDER_ICONS[providerId] + const pushOn = direction === 'both' || direction === 'push' + const pullOn = direction === 'both' || direction === 'pull' + + const hint = disabled + ? t(`docsync.flow.summary.${direction}`, { provider: providerName }) + : t(`docsync.flow.summaryEditable.${direction}`, { provider: providerName }) + + /** Turning a lane off leaves the other one; turning the last one off is refused. */ + const toggle = (lane: 'push' | 'pull') => { + if (disabled) return + const nextPush = lane === 'push' ? !pushOn : pushOn + const nextPull = lane === 'pull' ? !pullOn : pullOn + if (!nextPush && !nextPull) return + onChange(nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull') + } + + return ( +
+
+ } + /> + +
+ toggle('push')} + > + + + toggle('pull')} + > + + + + {/* Under the lanes rather than under the whole bar: it explains what + the two buttons above it are doing, and sitting anywhere else + makes the reader look for what it refers to. */} +

{hint}

+
+ + : null} + /> +
+
+ ) +} + +/** + * One end of the flow: the mark, the count, the name. + * + * The count is the largest thing in the box on purpose — "is my stuff over + * there" is the question that makes somebody open this dialog, and it should be + * answered before they read a word. + */ +function End({ name, count, glyph }: { name: string; count: number; glyph: React.ReactNode }) { + const { t } = useTranslation() + return ( +
+ {glyph} + + + {count} + + {t('docsync.flow.documents')} + + + + + {name} + +
+ ) +} + +/** + * One direction, as a track that is either carrying or idle. + * + * An idle track stays visible as a dashed line, so switching a direction off + * reads as a road nobody drives on rather than as something that disappeared. + */ +function Lane({ + active, + running, + disabled, + caption, + reverse, + onClick, + children, +}: { + active: boolean + running?: boolean + disabled?: boolean + caption: string + reverse?: boolean + onClick: () => void + children: React.ReactNode +}) { + const moving = active && running + return ( + + ) +} diff --git a/client/src/components/Files/docsync/DocSyncPanel.test.tsx b/client/src/components/Files/docsync/DocSyncPanel.test.tsx new file mode 100644 index 000000000..b179417f1 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncPanel.test.tsx @@ -0,0 +1,370 @@ +// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-016 + +/** + * The document-sync dialog shell (#2391). + * + * The panel itself owns three decisions: which store is open, which of them a + * person is even offered, and whether the "needs a look" list is worth the + * space. Everything below it — the binding card, the flow bar, the connect and + * scope modals — is tested where it lives; what is exercised here is the shell + * around them, driven through the real `useDocSync` against a mocked + * `docsyncApi`, because the selection rules are a reaction to what the server + * answers rather than to a prop. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import en from '@trek/shared/i18n/en' +import { act, render, screen, within, fireEvent, waitFor } from '../../../../tests/helpers/render' + +/** What the dialog actually renders for a key — the same fallback chain `t()` uses. */ +const t = (key: string): string => (en as unknown as Record)[key] ?? key + +const providers = vi.fn() +const listConnections = vi.fn() +const listLinks = vi.fn() +const status = vi.fn() +const deleteLink = vi.fn() +const updateLink = vi.fn() +const syncNow = vi.fn() + +vi.mock('../../../api/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + docsyncApi: { + ...actual.docsyncApi, + providers: (tripId: number | string) => providers(tripId), + listConnections: (tripId: number | string) => listConnections(tripId), + listLinks: (tripId: number | string) => listLinks(tripId), + status: (tripId: number | string) => status(tripId), + deleteLink: (tripId: number | string, linkId: number) => deleteLink(tripId, linkId), + updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLink(tripId, linkId, patch), + syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNow(tripId, linkId, full), + }, + } +}) + +import DocSyncPanel from './DocSyncPanel' +import type { DocSyncLink, DocSyncProvider } from './useDocSync' + +const provider = (id: string, name: string, available = true): DocSyncProvider => ({ + id, + name, + description: null, + icon: id, + available, + fields: [], +}) + +const link = (id: number, providerId: string, overrides: Partial = {}): DocSyncLink => ({ + id, + connectionId: id, + providerId, + scopeKey: `scope-${id}`, + remoteLabel: `Folder ${id}`, + remoteRootPath: `/TREK/folder-${id}`, + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + lastSyncAt: null, + lastSyncState: 'ok', + lastSyncError: null, + webhookUrl: null, + ...overrides, +}) + +/** What the server answers, as one object the tests reshape per case. */ +function serverHas({ + providers: p = [], + links = [], + items = {}, +}: { + providers?: DocSyncProvider[] + links?: DocSyncLink[] + items?: Record +}): void { + providers.mockResolvedValue(p) + listConnections.mockResolvedValue([]) + listLinks.mockResolvedValue(links) + status.mockResolvedValue({ + items, + links: links.map(l => ({ id: l.id, holdings: { inTrek: 3, atProvider: 3, paired: 3, missing: 0 } })), + }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(r => { resolve = r }) + return { promise, resolve } +} + +const onClose = vi.fn() + +/** The detail column's binding card. */ +const binding = () => screen.getByRole('article') + +/** The store list beside it. */ +const sidebar = () => screen.getByRole('navigation') + +/** The stores filed under one sidebar heading. */ +const group = (heading: string) => + within(screen.getByRole('heading', { name: heading }).parentElement as HTMLElement) + +beforeEach(() => { + vi.clearAllMocks() + serverHas({}) + deleteLink.mockResolvedValue({}) + updateLink.mockResolvedValue({}) + syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) +}) + +describe('DocSyncPanel — first load', () => { + it('FE-DOCSYNC-PANEL-001: shows a spinner while the first load is in flight and nothing else', async () => { + const gate = deferred() + providers.mockReturnValue(gate.promise) + + render() + + // The spinner carries no text of its own; the spin is the whole affordance. + expect(document.querySelector('.animate-spin')).not.toBeNull() + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument() + + await act(async () => { gate.resolve([]) }) + }) + + it('FE-DOCSYNC-PANEL-002: replaces the spinner with the store list once the load lands', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')] }) + + render() + + expect(await screen.findByRole('navigation')).toBeInTheDocument() + expect(document.querySelector('.animate-spin')).toBeNull() + }) + + it('FE-DOCSYNC-PANEL-003: says there is nothing to configure when the instance offers no provider', async () => { + serverHas({ providers: [] }) + + render() + + expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument() + expect(screen.getByText(t('docsync.noProvidersHint'))).toBeInTheDocument() + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-004: a provider switched on without a working adapter is not offered', async () => { + serverHas({ providers: [provider('papra', 'Papra', false)] }) + + render() + + expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument() + expect(screen.queryByText('Papra')).not.toBeInTheDocument() + }) +}) + +describe('DocSyncPanel — the store list', () => { + it('FE-DOCSYNC-PANEL-005: files the bound stores under one heading and the rest under another', async () => { + serverHas({ + providers: [ + provider('paperless', 'Paperless-ngx'), + provider('nextcloud', 'Nextcloud'), + provider('opencloud', 'OpenCloud'), + ], + links: [link(1, 'paperless')], + }) + + render() + await screen.findByRole('navigation') + + const connected = group(t('docsync.sidebar.connected')) + expect(connected.getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument() + expect(connected.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument() + + const addable = group(t('docsync.addAnother')) + expect(addable.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument() + expect(addable.getByRole('button', { name: /OpenCloud/ })).toBeInTheDocument() + expect(addable.queryByRole('button', { name: /Paperless-ngx/ })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-006: with nothing bound the list invites a first connection instead of another one', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] }) + + render() + await screen.findByRole('navigation') + + expect(screen.getByRole('heading', { name: t('docsync.addProvider') })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: t('docsync.sidebar.connected') })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-007: opens the first bound store without being asked', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')], + links: [link(1, 'paperless'), link(2, 'nextcloud')], + }) + + render() + + const card = await screen.findByRole('article') + // The card names its provider twice (header and flow bar), so the folder is + // what says which of the two bindings is open. + expect(within(card).getByText('Folder 1')).toBeInTheDocument() + expect(within(card).queryByText('Folder 2')).not.toBeInTheDocument() + expect(within(card).getAllByText('Paperless-ngx').length).toBeGreaterThan(0) + expect(within(card).queryByText('Nextcloud')).not.toBeInTheDocument() + expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true') + }) + + it('FE-DOCSYNC-PANEL-008: picking a store swaps the detail column and moves the marker with it', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')], + links: [link(1, 'paperless'), link(2, 'nextcloud')], + }) + + render() + await screen.findByRole('article') + + const second = within(sidebar()).getByRole('button', { name: /Nextcloud/ }) + fireEvent.click(second) + + expect(within(binding()).getByText('Folder 2')).toBeInTheDocument() + expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument() + expect(within(binding()).getAllByText('Nextcloud').length).toBeGreaterThan(0) + expect(within(binding()).queryByText('Paperless-ngx')).not.toBeInTheDocument() + expect(second).toHaveAttribute('aria-current', 'true') + expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current') + }) +}) + +describe('DocSyncPanel — a member rather than the owner', () => { + it('FE-DOCSYNC-PANEL-009: is shown where the documents go but is offered no store to add', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')], + links: [link(1, 'paperless')], + }) + + render() + await screen.findByRole('navigation') + + expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument() + expect(within(sidebar()).queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-010: with nothing bound is told the owner sets this up, not to pick a store', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] }) + + render() + + expect(await screen.findByText(t('docsync.empty.hintMember'))).toBeInTheDocument() + expect(screen.queryByText(t('docsync.empty.hintOwner'))).not.toBeInTheDocument() + expect(within(sidebar()).queryAllByRole('button')).toHaveLength(0) + }) +}) + +describe('DocSyncPanel — removing a binding', () => { + it('FE-DOCSYNC-PANEL-011: moves the selection to the binding that is left instead of emptying the column', async () => { + const paperless = link(1, 'paperless') + const nextcloud = link(2, 'nextcloud') + const both = [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')] + serverHas({ providers: both, links: [paperless, nextcloud] }) + // The unlink lands on the server, and the reload after it is what the panel + // reacts to — so that is where the row disappears. + deleteLink.mockImplementation(async () => { + serverHas({ providers: both, links: [paperless] }) + return {} + }) + + render() + await screen.findByRole('article') + + fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ })) + expect(within(binding()).getByText('Folder 2')).toBeInTheDocument() + + fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') })) + + await waitFor(() => expect(deleteLink).toHaveBeenCalledWith(7, 2)) + await waitFor(() => expect(within(binding()).getByText('Folder 1')).toBeInTheDocument()) + expect(screen.queryByText(t('docsync.empty.title'))).not.toBeInTheDocument() + expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true') + }) + + it('FE-DOCSYNC-PANEL-012: removing the last binding leaves the invitation, not a blank column', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] }) + deleteLink.mockImplementation(async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] }) + return {} + }) + + render() + await screen.findByRole('article') + + fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') })) + + expect(await screen.findByText(t('docsync.empty.title'))).toBeInTheDocument() + expect(screen.getByText(t('docsync.empty.hintOwner'))).toBeInTheDocument() + expect(screen.queryByRole('article')).not.toBeInTheDocument() + }) +}) + +describe('DocSyncPanel — what needs a look', () => { + it('FE-DOCSYNC-PANEL-013: stays quiet when nothing needs attention', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')], items: {} }) + + render() + await screen.findByRole('article') + + expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-014: counts that need no decision do not open the issues panel', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx')], + links: [link(1, 'paperless')], + items: { pending: 9, synced: 41, local_deleted: 2 }, + }) + + render() + await screen.findByRole('article') + + expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-015: names only the states needing attention, with their counts', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx')], + links: [link(1, 'paperless')], + items: { conflict: 2, too_large: 1, pending: 9, synced: 41 }, + }) + + render() + await screen.findByRole('article') + + const panel = within( + screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement, + ) + const rows = panel.getAllByRole('listitem') + expect(rows).toHaveLength(2) + expect(within(rows[0]).getByText(t('docsync.state.conflict'))).toBeInTheDocument() + expect(within(rows[0]).getByText(t('docsync.issues.conflict'))).toBeInTheDocument() + expect(within(rows[0]).getByText('2')).toBeInTheDocument() + expect(within(rows[1]).getByText(t('docsync.state.too_large'))).toBeInTheDocument() + expect(within(rows[1]).getByText('1')).toBeInTheDocument() + expect(panel.queryByText(t('docsync.state.pending'))).not.toBeInTheDocument() + }) +}) + +describe('DocSyncPanel — the dialog itself', () => { + it('FE-DOCSYNC-PANEL-016: names the trip under the title and closes from the header', async () => { + serverHas({ providers: [] }) + + render() + await screen.findByText(t('docsync.noProviders')) + + expect(screen.getByText(t('docsync.title'))).toBeInTheDocument() + expect(screen.getByText('Iceland 2026')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button')) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncPanel.tsx b/client/src/components/Files/docsync/DocSyncPanel.tsx index 6d4225b56..51af8268d 100644 --- a/client/src/components/Files/docsync/DocSyncPanel.tsx +++ b/client/src/components/Files/docsync/DocSyncPanel.tsx @@ -1,343 +1,332 @@ -import { useState } from 'react' -import { AlertTriangle, Check, Cloud, FolderSync, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react' +import Modal from '../../shared/Modal' import { useTranslation } from '../../../i18n/TranslationContext' -import ToggleSwitch from '../../Settings/ToggleSwitch' -import { useDocSync, type DocSyncProvider, type DocSyncScope } from './useDocSync' +import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' +import { StateBadge } from './DocSyncBits' +import { useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync' +import DocSyncBinding from './DocSyncBinding' +import DocSyncConnectModal from './DocSyncConnectModal' +import DocSyncScopeModal from './DocSyncScopeModal' /** - * The trip's document sync, configured where the documents are. + * Document sync for one trip, as a dialog with the stores down one side and the + * selected one open beside it. * - * It lives in the file manager rather than in settings because this is the one - * screen where someone has the context "these documents belong in that folder". - * Only the trip owner can change it — the credential usually reaches the - * owner's whole archive — but every member can see where their documents go, - * which is the minimum a shared folder owes the people sharing it. + * Two columns rather than a stack, because the two questions are different + * shapes: "which store" is a short list somebody scans, and "how does this one + * behave" is a screenful. Stacked, the second answer pushed the first one off + * the top every time a binding was added. + * + * It opens from the file manager rather than from settings: this is the one + * screen where a person has the context that makes the folder choice obvious. + * Only the trip owner can change a binding — the credential usually reaches + * their whole archive — but every member sees where their documents go, which + * is the minimum a shared folder owes the people sharing it. */ -export default function DocSyncPanel({ tripId, isOwner, onClose }: { tripId: number | string; isOwner: boolean; onClose: () => void }) { - const { t } = useTranslation() - const sync = useDocSync(tripId, true) - const [openProvider, setOpenProvider] = useState(null) - - if (sync.loading) { - return ( -
- -
- ) - } - - if (sync.providers.length === 0) { - return ( -
- -

{t('docsync.noProviders')}

-

{t('docsync.noProvidersHint')}

-
- ) - } - - return ( -
-
-
-

{t('docsync.title')}

-

{t('docsync.subtitle')}

-
- -
- - {sync.links.map(link => { - const provider = sync.providers.find(p => p.id === link.providerId) - return ( -
-
-
-
- - {provider?.name || link.providerId} - -
-

- {link.remoteLabel || link.remoteRootPath || link.scopeKey} -

-
-
- - {isOwner && ( - - )} -
-
- - {link.lastSyncError && ( -

- - {t(`docsync.error.${link.lastSyncError}`)} -

- )} - - {isOwner && ( -
- - -
- {t('docsync.syncEnabled')} - void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })} - /> -
- {/* Shown only where TREK could not subscribe itself: Papra's - webhook API is closed to API keys and Nextcloud's needs admin - rights, so the user pastes this in by hand. Polling carries - the binding either way. */} - {link.webhookUrl && ( -
-

{t('docsync.webhookHint')}

- - {link.webhookUrl} - -
- )} -
- )} -
- ) - })} - - {Object.keys(sync.itemCounts).length > 0 && ( -
- {Object.entries(sync.itemCounts).map(([state, n]) => ( - - {t(`docsync.state.${state}`)}: {n} - - ))} -
- )} - - {isOwner && ( -
-

{t('docsync.addProvider')}

- {sync.providers.map(p => ( - setOpenProvider(openProvider === p.id ? null : p.id)} - /> - ))} -
- )} -
- ) -} - -function StateBadge({ state, t }: { state: string; t: (k: string) => string }) { - const tone = - state === 'ok' ? 'text-success' - : state === 'never' ? 'text-content-faint' - : state === 'partial' ? 'text-warning' - : 'text-danger' - return {t(`docsync.linkState.${state}`)} -} - -/** One provider: credentials, a probe, then a folder to bind the trip to. */ -function ProviderRow({ - provider, tripId, sync, open, onOpen, +export default function DocSyncPanel({ + tripId, + tripTitle, + isOwner, + onClose, }: { - provider: DocSyncProvider tripId: number | string - sync: ReturnType - open: boolean - onOpen: () => void + tripTitle?: string + isOwner: boolean + onClose: () => void }) { const { t } = useTranslation() - const existing = sync.connectionFor(provider.id) - const [values, setValues] = useState>({}) - const [insecure, setInsecure] = useState(existing?.allowInsecureTls ?? false) - const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) - const [scopes, setScopes] = useState(null) - const [newScopeName, setNewScopeName] = useState('') + const sync = useDocSync(tripId, true) + const [selected, setSelected] = useState(null) + const [connecting, setConnecting] = useState(null) + const [scopeFor, setScopeFor] = useState(null) - const baseUrl = values.base_url ?? existing?.baseUrl ?? '' - const field = (key: string) => values[key] ?? existing?.settings?.[key] ?? '' + const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links]) + const available = sync.providers.filter(p => !bound.has(p.id)) + const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null - const runTest = async () => { - const res = await sync.testConnection(provider.id, baseUrl, values, insecure) - setVerdict(res) - } + // Follow the list: a freshly bound store should be the one on screen, and a + // removed one must not leave the detail column pointing at nothing. + useEffect(() => { + if (sync.links.length === 0) setSelected(null) + else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id) + }, [sync.links, selected]) - const save = async () => { - const ok = await sync.saveConnection(provider.id, baseUrl, values, insecure) - if (ok) setVerdict({ connected: true }) - } - - const loadScopes = async () => { - const conn = sync.connectionFor(provider.id) - if (!conn) return - const res = await sync.loadScopes(conn.id) - setScopes(res.scopes) - } - - const bind = async (scope: DocSyncScope) => { - const conn = sync.connectionFor(provider.id) - if (!conn) return - await sync.createLink({ - connectionId: conn.id, - scopeKey: scope.scopeKey, - remoteRootId: scope.remoteRootId, - remoteRootPath: scope.remoteRootPath, - remoteLabel: scope.label, - direction: 'both', - deletePolicy: 'unlink', - conflictPolicy: 'manual', - syncEnabled: true, - }) - setScopes(null) - } + const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0) return ( -
- + <> + + {t('docsync.title')} + {tripTitle && ( + {tripTitle} + )} + + } + > + {sync.loading ? ( +
+ +
+ ) : sync.providers.length === 0 ? ( + + ) : ( +
+ (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))} + /> - {open && ( -
- {provider.fields.map(f => ( -