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 => ( -