diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index 474845ec1..eebc8f933 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-054 +// FE-APISURF-001 to FE-APISURF-056 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -10,6 +10,7 @@ import { mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi, + docsyncApi, } from './client' interface Recorded { method: string; url: string; body: unknown } @@ -665,6 +666,12 @@ describe('client > request payloads', () => { expect(rec.method).toBe('DELETE') expect(rec.body).toEqual({ password: 'hunter2' }) }) + + it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => { + const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway')) + expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes') + expect(rec.body).toEqual({ name: 'Norway' }) + }) }) describe('client > query parameters', () => { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4f7a8ab63..6d9ca3589 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1358,6 +1358,36 @@ export const filesApi = { getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } +/** + * Document sync: one provider connection per trip, shared by every member. + * + * Trip-scoped rather than user-scoped on purpose: a per-user connection would + * make a document's visibility depend on whose credentials fetched it, which is + * the opposite of what a shared trip needs. + */ +export const docsyncApi = { + providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data), + status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data), + listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data), + saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data), + testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data), + deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data), + listScopes: (tripId: number | string, connectionId: number, q?: string) => + apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data), + createScope: (tripId: number | string, connectionId: number, name: string) => + apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }).then(r => r.data), + listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data), + createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data), + updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data), + deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data), + syncNow: (tripId: number | string, linkId: number, full = false) => + apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data), + items: (tripId: number | string, state?: string) => + apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data), + resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') => + apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data), +} + export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 268083586..504b8bd38 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -81,6 +81,12 @@ 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, and components/Files/docsync/useDocSyncOffered asks + // again whether the Files screen offers the sync button. 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.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index ad5f1ccb0..0964cedc0 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-036 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -418,7 +418,102 @@ describe('AddonManager', () => { expect(isOn(addonToggle('Todo List'))).toBe(true); }); - it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }), + buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Paperless-ngx'); + const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement; + expect(paperlessRow.querySelector('svg')).toBeInTheDocument(); + expect(isOn(subToggle('Paperless-ngx'))).toBe(true); + expect(isOn(subToggle('Nextcloud'))).toBe(false); + // Documents and Journey are the addons; an enabled provider is a shelf row + // and must not turn 1/2 into 2/2. + expect(screen.getByText('1/2')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), + ])); + render(); + + await screen.findByText('Documents'); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let documentsOn = true; + let paperlessOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }), + ], + }); + }), + http.put('/api/admin/addons/documents', async ({ request }) => { + documentsOn = (await request.json() as { enabled: boolean }).enabled; + if (!documentsOn) paperlessOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(addonToggle('Documents')); + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + + await user.click(addonToggle('Documents')); + + await screen.findByText('Paperless-ngx'); + expect(isOn(subToggle('Paperless-ngx'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => { + const user = userEvent.setup(); + let loads = 0; + let body: unknown = null; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }), + ], + }); + }), + http.put('/api/admin/addons/papra', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Papra'); + + await user.click(subToggle('Papra')); + + await screen.findByText('Addon updated'); + expect(body).toEqual({ enabled: true }); + expect(loads).toBe(1); + expect(isOn(subToggle('Papra'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 2deda0596..6002d42c7 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 } from '../shared/DocumentProviderIcons' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' @@ -41,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) { const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, + // The document providers' own marks, single-colour so they read as glyphs in + // a row of lucide icons and invert with the theme. + ...DOCUMENT_PROVIDER_ICONS, } interface Addon { @@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf // up with providers the database has long since turned off. - if (addon.id === 'journey') await loadAddons() + // Same for Documents: switching it off disables every document provider in + // the database, and the response carries only the addon itself. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() toast.success(t('admin.addons.toast.updated')) } @@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return addon.type === 'photo_provider' } + const isDocumentProviderAddon = (addon: Addon) => { + return addon.type === 'document_provider' + } + const isPhotosAddon = (addon: Addon) => { const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) } const photoProviderAddons = addons.filter(isPhotoProviderAddon) + const documentProviderAddons = addons.filter(isDocumentProviderAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') @@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, enabled: provider.enabled, toggle: () => handleToggle(provider), })) + const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })) if (loading) { return ( @@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } + // Document providers are the Documents tile's shelf, the way photo providers + // are Journey's. Unlike those they carry no credential form here: a document + // connection belongs to a trip, not to a user, so it is entered in the trip's + // file manager. The admin decides only whether a provider may be offered. + if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { + return documentProviderOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return ( + : undefined} + title={provider.label} + description={provider.description} + enabled={provider.enabled} + onToggle={provider.toggle} + /> + ) + }) + } if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { return providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] @@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, ].filter(g => g.addons.length > 0) - const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length + const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length return ( diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index a4ea825f8..7b65ecf81 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043 +// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048 import React from 'react'; import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -602,3 +602,89 @@ describe('CollectionPlaceDetail: editable address', () => { expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere'); }); }); + +// ── Cover controls sit beside the close button ─────────────────────────────── + +describe('CollectionPlaceDetail: cover controls', () => { + it('FE-COMP-COLDETAIL-044: the camera sits in the top right row, just left of the close button', async () => { + renderDetail({ canEdit: true, onUploadImage: vi.fn() }); + const camera = await screen.findByRole('button', { name: 'Upload image' }); + const close = screen.getByRole('button', { name: 'Close' }); + + const row = close.parentElement as HTMLElement; + expect(camera.parentElement).toBe(row); + expect(camera.nextElementSibling).toBe(close); + // Same class as the close button, so the same round look, hover and phone size. + expect(camera).toHaveClass('col-detail-close'); + // The category chip keeps the top left corner to itself. + const chip = screen.getByText('Food').closest('.col-detail-cover-cat') as HTMLElement; + expect(row.contains(chip)).toBe(false); + }); + + it('FE-COMP-COLDETAIL-045: with a custom cover the row reads remove, change, close and change still opens the picker', async () => { + const user = userEvent.setup(); + renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, image_url: '/uploads/places/mock.jpg' } }); + const change = await screen.findByRole('button', { name: 'Change image' }); + const remove = screen.getByRole('button', { name: 'Remove image' }); + const close = screen.getByRole('button', { name: 'Close' }); + + expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]); + expect(remove).toHaveClass('col-detail-close'); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clicked = vi.fn(); + input.addEventListener('click', clicked); + await user.click(change); + expect(clicked).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-COLDETAIL-046: while an upload runs the camera spins, ignores clicks and the remove control steps aside', async () => { + const user = userEvent.setup(); + const onUploadImage = vi.fn(() => new Promise(() => {})); + renderDetail({ canEdit: true, onUploadImage, place: { ...place, image_url: '/uploads/places/mock.jpg' } }); + await screen.findByRole('button', { name: 'Change image' }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } }); + + const change = screen.getByRole('button', { name: 'Change image' }); + await waitFor(() => expect(change.querySelector('.animate-spin')).not.toBeNull()); + expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument(); + expect(change.nextElementSibling).toBe(screen.getByRole('button', { name: 'Close' })); + + const clicked = vi.fn(); + input.addEventListener('click', clicked); + await user.click(change); + expect(clicked).not.toHaveBeenCalled(); + }); + + it('FE-COMP-COLDETAIL-047: a long category shares the top bar with the controls and ellipsizes instead of running under them', async () => { + const long = 'Restaurants, cafes and late night bars'; + renderDetail({ + canEdit: true, + onUploadImage: vi.fn(), + place: { ...place, image_url: '/uploads/places/mock.jpg', category: { id: 1, name: long, color: '#f00', icon: null } }, + }); + const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement; + const text = screen.getByText(long); + const chip = text.closest('.col-detail-cover-cat') as HTMLElement; + const bar = controls.parentElement as HTMLElement; + + expect(controls.querySelectorAll('button')).toHaveLength(3); + // Chip first, controls last in one flex bar: the chip only gets what the buttons leave. + expect(Array.from(bar.children)).toEqual([chip, controls]); + expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]'); + expect(controls).toHaveClass('ms-auto', 'flex-none'); + expect(chip).toHaveClass('min-w-0'); + expect(text).toHaveClass('truncate'); + }); + + it('FE-COMP-COLDETAIL-048: without a category the controls still hold the end of the bar', async () => { + renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, category: undefined } }); + const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement; + + expect(document.querySelector('.col-detail-cover-cat')).toBeNull(); + expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([controls]); + expect(controls).toHaveClass('ms-auto'); + }); +}); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index ec87ebe44..7574c95ef 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
{banner && }
- {place.category?.name && ( - - {place.category.name} - - )} - - {canEdit && onUploadImage && ( -
- - - - {place.image_url && !imgBusy && ( - - - + {/* Chip and cover controls share the top bar. The controls keep the end and + never shrink, so a long category name ellipsizes instead of sliding under + them, however many buttons there are and whatever size phones give them. */} +
+ {place.category?.name && ( + + + {place.category.name} + + )} + {/* The cover controls wear the close button's class, so they get its look, + hover and phone size. */} +
+ {canEdit && onUploadImage && ( + <> + + {place.image_url && !imgBusy && ( + + + + )} + + + + )} - +
- )} +
{editing ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 0ff151f32..7bd24504e 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -1,10 +1,11 @@ -// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012 +// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-038 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; +import { useDocSyncOfferStore } from '../../store/docSyncOfferStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip } from '../../../tests/helpers/factories'; import type { TripFile } from '../../types'; @@ -104,6 +105,15 @@ beforeEach(() => { }), ); + // Document sync, as a fresh install has it: every provider off, nothing bound. + useDocSyncOfferStore.setState({ bound: {}, providers: null }); + server.use( + http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])), + http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([])), + http.get('/api/trips/:tripId/docsync/connections', () => HttpResponse.json([])), + http.get('/api/trips/:tripId/docsync/status', () => HttpResponse.json({ links: [], items: {} })), + ); + // Stub window.confirm vi.spyOn(window, 'confirm').mockReturnValue(true); }); @@ -631,4 +641,60 @@ describe('FileManager', () => { expect(call[0]).toBeInstanceOf(FormData); }); }); + + describe('document sync button', () => { + const paperless = { id: 'paperless', name: 'Paperless-ngx', description: null, icon: 'paperless', available: true, fields: [] }; + const trashButton = () => screen.getByText('Trash').closest('button') as HTMLButtonElement; + + it('FE-COMP-FILEMANAGER-036: with no provider on and nothing bound there is no sync button, and the trash sits flush right', async () => { + const asked: string[] = []; + server.use( + http.get('/api/trips/:tripId/docsync/links', () => { + asked.push('links'); + return HttpResponse.json([]); + }), + ); + render(); + + await waitFor(() => expect(asked).toContain('links')); + expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument(); + expect(trashButton()).toHaveStyle({ marginLeft: 'auto' }); + }); + + it('FE-COMP-FILEMANAGER-037: somebody who may bind the trip gets it once a provider is on, and it opens the panel', async () => { + server.use(http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless]))); + render(); + const user = userEvent.setup(); + + const button = await screen.findByTitle('Document sync'); + expect(button).toHaveStyle({ marginLeft: 'auto' }); + expect(trashButton().style.marginLeft).toBe(''); + + await user.click(button); + expect(await screen.findByText('Connect a provider')).toBeInTheDocument(); + }); + + it('FE-COMP-FILEMANAGER-038: a member sees it on a bound trip only', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true }); + server.use( + http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless])), + http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }])), + ); + const { unmount } = render(); + expect(await screen.findByTitle('Document sync')).toBeInTheDocument(); + unmount(); + + useDocSyncOfferStore.setState({ bound: {}, providers: null }); + const asked: string[] = []; + server.use( + http.get('/api/trips/:tripId/docsync/links', () => { + asked.push('links'); + return HttpResponse.json([]); + }), + ); + render(); + await waitFor(() => expect(asked).toContain('links')); + expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 74b9d22f3..cf5db4418 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -7,6 +7,7 @@ import { isMarkdown } from './FileManager.helpers' import { FileManagerToolbar } from './FileManagerToolbar' import { TrashView } from './FileManagerTrashView' import { FilesView } from './FileManagerFilesView' +import DocSyncPanel from './docsync/DocSyncPanel' export default function FileManager(props: FileManagerProps) { const S = useFileManager(props) @@ -24,6 +25,19 @@ export default function FileManager(props: FileManagerProps) { ? : )} + {/* 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)} + /> + )} + {/* Toolbar */} diff --git a/client/src/components/Files/FileManagerToolbar.tsx b/client/src/components/Files/FileManagerToolbar.tsx index 2c77017e1..9e0d33d24 100644 --- a/client/src/components/Files/FileManagerToolbar.tsx +++ b/client/src/components/Files/FileManagerToolbar.tsx @@ -1,8 +1,8 @@ -import { Star, Trash2 } from 'lucide-react' +import { FolderSync, Star, Trash2 } from 'lucide-react' import type { FileManagerState } from './useFileManager' export function FileManagerToolbar(S: FileManagerState) { - const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S + const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync, docSyncOffered } = S return (
)} + {/* Opens the sync panel. Placed next to the trash rather than in a menu + because the question it answers, "where do these documents live", + belongs on the same screen as the documents. Only where there is + something behind it: see useDocSyncOffered. */} + {docSyncOffered && ( + + )} + + {/* The first of the two takes the free space, so without the sync + button the trash sits flush right the way it always has. */} + {canManage && ( + + + + )} + + + + {notice && ( +

+ + {notice} +

+ )} + +
+ void sync.updateLink(link.id, { direction: next })} + /> +
+ + {canManage && ( + <> + + + {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, { conflictPolicy: String(v) })} + options={CONFLICT_POLICIES.map(p => ({ value: p, label: t(conflictPolicyKey(p)) }))} + /> + + + + + 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..9dfb6e860 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncBits.tsx @@ -0,0 +1,107 @@ +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)} +} + +/** + * The three answers to "both sides changed", in the order they are offered. + * + * Shared because the phone offers them as one cycling button and the desktop as + * a dropdown: two lists would drift, and a binding would then say one thing on + * a laptop and another on a phone. + */ +export const CONFLICT_POLICIES = ['manual', 'trek_wins', 'provider_wins'] as const + +export type ConflictPolicy = (typeof CONFLICT_POLICIES)[number] + +/** The next answer in that ring, for the phone's single button. */ +export function nextConflictPolicy(current: string): ConflictPolicy { + const at = (CONFLICT_POLICIES as readonly string[]).indexOf(current) + return CONFLICT_POLICIES[(at + 1) % CONFLICT_POLICIES.length] +} + +/** The label key for one answer. */ +export function conflictPolicyKey(policy: string): string { + return `docsync.onConflict.${(CONFLICT_POLICIES as readonly string[]).includes(policy) ? policy : 'manual'}` +} 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..f2414c048 --- /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..f9a04fd65 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncConnectModal.tsx @@ -0,0 +1,189 @@ +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 { useConnectForm } from './useConnectForm' +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 form = useConnectForm(provider, existing, sync) + const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) + + const test = async () => setVerdict(await form.probe()) + + const save = async () => { + if (await form.save()) onConnected(provider.id) + } + + return ( + + {Icon && } + {provider.name} + + } + footer={ +
+ +
+ + +
+
+ } + > +
+

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

+ + {form.fields.map(f => ( + + form.setValue(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, + failure, +}: { + verdict: { connected: boolean; account?: string; error?: string } | null + busy: boolean + /** A call that failed outside the connection test (saving, most of all). */ + failure: string | null +}) { + const { t } = useTranslation() + if (busy) { + return ( + + + {t('docsync.connect.testing')} + + ) + } + // A save that the server refused left the dialog open with the button back to + // normal and nothing else said, which reads as nothing having happened. The + // test verdict wins the space when there is one: it is the more specific + // answer and the one the person just asked for. + if (!verdict && failure) { + return ( + + + {t(`docsync.error.${failure}`)} + + ) + } + 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..2ddb5ed75 --- /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 TrekIcon from '../../shared/TrekIcon' +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..9cd137490 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncPanel.test.tsx @@ -0,0 +1,406 @@ +// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-018 + +/** + * 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, via 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-017: a bound trip keeps showing its binding after every provider was switched off', async () => { + serverHas({ providers: [], links: [link(1, 'paperless', { providerName: 'Paperless-ngx' })] }) + + render() + + expect(await screen.findByRole('article')).toBeInTheDocument() + expect(within(sidebar()).getByText('Folder 1')).toBeInTheDocument() + expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument() + // The providers route no longer names it, so the name comes off the link. + expect(within(sidebar()).getByText('Paperless-ngx')).toBeInTheDocument() + expect(within(binding()).getAllByText('Paperless-ngx').length).toBeGreaterThan(0) + expect(screen.queryByText('paperless')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-PANEL-018: a reload that finds the addon gone blanks the bindings too', async () => { + serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] }) + // Documents is switched off while the dialog is open: the run is refused + // and so is every route the reload after it asks. + const gone = { response: { status: 404 } } + syncNow.mockImplementation(async () => { + providers.mockRejectedValue(gone) + throw gone + }) + + render() + await screen.findByRole('article') + + fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') })) + + expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument() + expect(screen.queryByRole('article')).not.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() + // The conflict row carries a button rather than a bare count: it is the one + // of these a person can act on. + expect(within(rows[0]).getByRole('button', { name: /Resolve/ })).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 new file mode 100644 index 000000000..de5f2af10 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncPanel.tsx @@ -0,0 +1,389 @@ +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 { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' +import { StateBadge } from './DocSyncBits' +import { useConflicts } from './useConflicts' +import { storeName, useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync' +import DocSyncBinding from './DocSyncBinding' +import DocSyncConnectModal from './DocSyncConnectModal' +import DocSyncScopeModal from './DocSyncScopeModal' + +/** + * Document sync for one trip, as a dialog with the stores down one side and the + * selected one open beside 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, + tripTitle, + canManage, + onClose, +}: { + tripId: number | string + tripTitle?: string + canManage: boolean + onClose: () => void +}) { + const { t } = useTranslation() + const sync = useDocSync(tripId, true) + const [selected, setSelected] = useState(null) + const [connecting, setConnecting] = useState(null) + const [scopeFor, setScopeFor] = useState(null) + + 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 + + // 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 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.links.length === 0 ? ( + // Only with nothing bound: a binding outlives the admin switching its + // provider off, and the people sharing it still need to see it. + + ) : ( +
+ (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))} + /> + +
+ {active ? ( +
+ + {attention > 0 && } +
+ ) : ( + + )} +
+
+ )} + + + {connecting && ( + setConnecting(null)} + onConnected={id => { setConnecting(null); setScopeFor(id) }} + /> + )} + + {scopeFor && sync.connectionFor(scopeFor) && ( + p.id === scopeFor)?.name ?? scopeFor} + suggestedName={slugFor(tripTitle, tripId)} + sync={sync} + onClose={() => setScopeFor(null)} + onBound={() => setScopeFor(null)} + /> + )} + + ) +} + +const ATTENTION_STATES = ['conflict', 'remote_missing', 'rejected_type', 'too_large', 'error'] as const + +/** The stores: the ones this trip uses, then the ones it could. */ +function Sidebar({ + links, + providers, + available, + activeId, + canManage, + onSelect, + onAdd, +}: { + links: DocSyncLink[] + providers: DocSyncProvider[] + available: DocSyncProvider[] + activeId: number | null + canManage: boolean + onSelect: (id: number) => void + onAdd: (p: DocSyncProvider) => void +}) { + const { t } = useTranslation() + return ( + + ) +} + +/** + * One row in the store list. + * + * The selected row is marked with the accent on a subtle fill rather than a + * heavy border, so a list of five does not turn into five competing boxes. + */ +function StoreButton({ + providerId, + title, + subtitle, + state, + active, + addable, + onClick, +}: { + providerId: string + title: string + subtitle: string + state?: string + active?: boolean + addable?: boolean + onClick: () => void +}) { + const Icon = DOCUMENT_PROVIDER_ICONS[providerId] + return ( + + ) +} + +/** What a person still has to decide about, named rather than counted in a chip. */ +function Attention({ + counts, + tripId, + sync, +}: { + counts: Record + tripId: number | string + sync: ReturnType +}) { + const { t } = useTranslation() + const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0) + const [showConflicts, setShowConflicts] = useState(false) + const conflicts = useConflicts(tripId, sync, showConflicts) + + return ( +
+

+ + {t('docsync.issues.title')} +

+
    + {rows.map(k => ( +
  • +
    + + {t(`docsync.state.${k}`)} + {t(`docsync.issues.${k}`)} + + {/* A conflict is the one of these a person can act on, so it is + the one that opens. The rest are a state to read. */} + {k === 'conflict' ? ( + + ) : ( + {counts[k]} + )} +
    + + {k === 'conflict' && showConflicts && ( +
      + {conflicts.items === null && ( +
    • + +
    • + )} + {conflicts.items?.map(item => ( +
    • +

      {item.name}

      +

      {t('docsync.conflict.title')}

      +
      + {(['trek', 'provider', 'both'] as const).map(keep => ( + + ))} +
      +
    • + ))} +
    + )} +
  • + ))} +
+
+ ) +} + +/** The detail column before anything is bound: an invitation, not a blank. */ +function NothingBound({ canManage }: { canManage: boolean }) { + const { t } = useTranslation() + return ( +
+
+ + + +

{t('docsync.empty.title')}

+

+ {canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')} +

+
+
+ ) +} + +function Empty() { + const { t } = useTranslation() + return ( +
+ + + +

{t('docsync.noProviders')}

+

{t('docsync.noProvidersHint')}

+
+ ) +} + +/** + * A folder name from the trip's own title, so nobody has to invent one. + * + * The id is appended because two trips can share a title and a folder cannot. + */ +function slugFor(title: string | undefined, tripId: number | string): string { + const base = (title || 'trek') + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + return `${base || 'trek'}-${tripId}` +} diff --git a/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx b/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx new file mode 100644 index 000000000..3bea464d2 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx @@ -0,0 +1,367 @@ +// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-022 + +/** + * The folder picker of the document-sync dialog. + * + * What matters to somebody opening it: the folders of their own store show up, + * the one they pick is the one that gets bound, making a new folder is one + * action rather than two, and a store that refuses to answer says so instead of + * pretending the archive is empty. + * + * The component takes the `useDocSync` hook as a prop, so the double here is the + * real hook with `api/client` mocked underneath, so the assertions are about + * the requests that actually leave, not about a hand-written stand-in. The hook + * is created with `enabled: false` because the panel-level lists it would fetch + * belong to the dialog around this modal, not to the picker. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import en from '@trek/shared/i18n/en' +import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render' +import type { DocSyncConnection, DocSyncScope } from './useDocSync' + +const S = en as unknown as Record + +const listScopes = vi.fn() +const createScope = vi.fn() +const createLink = vi.fn() + +vi.mock('../../../api/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + docsyncApi: { + ...actual.docsyncApi, + providers: () => Promise.resolve([]), + status: () => Promise.resolve({ items: {}, links: [] }), + listConnections: () => Promise.resolve([]), + listLinks: () => Promise.resolve([]), + listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopes(tripId, connectionId, q), + createScope: (tripId: number | string, connectionId: number, name: string) => createScope(tripId, connectionId, name), + createLink: (tripId: number | string, data: unknown) => createLink(tripId, data), + }, + } +}) + +import DocSyncScopeModal from './DocSyncScopeModal' +import { useDocSync } from './useDocSync' + +const TRIP_ID = 42 + +const connection: DocSyncConnection = { + id: 5, + providerId: 'paperless', + baseUrl: 'https://docs.example.org', + settings: {}, + secrets: {}, + allowInsecureTls: false, + lastProbeState: 'ok', + lastProbeError: null, +} + +const folder = (n: number, label: string, remoteRootPath: string | null = `/Documents/${label}`): DocSyncScope => + ({ scopeKey: `folder-${n}`, label, remoteRootId: String(n), remoteRootPath }) + +/** Seven, so the list is one over the threshold that earns a search box. */ +const SEVEN = [ + folder(1, 'Trips'), + folder(2, 'Archive'), + folder(3, 'Receipts'), + folder(4, 'Invoices'), + folder(5, 'Photos'), + folder(6, 'Manuals'), + folder(7, 'Warranty'), +] +const SIX = SEVEN.slice(0, 6) +const THREE = SEVEN.slice(0, 3) + +const onClose = vi.fn() +const onBound = vi.fn() + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: { + suggestedName?: string + providerName?: string +}) { + const sync = useDocSync(TRIP_ID, false) + return ( + + ) +} + +const nameField = () => screen.getByPlaceholderText(S['docsync.newFolderPlaceholder']) +const createButton = () => screen.getByRole('button', { name: S['docsync.scope.createAction'] }) +const searchField = () => screen.queryByPlaceholderText(S['docsync.scope.search']) +const rowFor = (label: string) => screen.getByText(label).closest('button') as HTMLButtonElement + +beforeEach(() => { + vi.clearAllMocks() + listScopes.mockResolvedValue({ scopes: THREE }) + createScope.mockResolvedValue(folder(9, 'Paris 2026')) + createLink.mockResolvedValue({ id: 1 }) +}) + +describe('DocSyncScopeModal list', () => { + it('FE-DOCSYNC-SCOPE-001: asks the store for its folders as soon as it opens', async () => { + render() + + await waitFor(() => expect(listScopes).toHaveBeenCalledWith(TRIP_ID, 5, undefined)) + }) + + it('FE-DOCSYNC-SCOPE-002: shows every folder the store answered with, and where each one sits', async () => { + listScopes.mockResolvedValue({ scopes: THREE }) + render() + + expect(await screen.findByText('Trips')).toBeInTheDocument() + expect(screen.getByText('Archive')).toBeInTheDocument() + expect(screen.getByText('Receipts')).toBeInTheDocument() + expect(screen.getByText('/Documents/Archive')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-003: says nothing about an empty archive while the list is still in flight', async () => { + const pending = deferred<{ scopes: DocSyncScope[] }>() + listScopes.mockReturnValue(pending.promise) + render() + + // The dangerous middle state: "no folders" is a claim about the store, and + // it must not be made before the store has answered. + expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument() + expect(screen.queryByText('Trips')).not.toBeInTheDocument() + + pending.resolve({ scopes: THREE }) + expect(await screen.findByText('Trips')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-004: a store with no folders at all says so', async () => { + listScopes.mockResolvedValue({ scopes: [] }) + render() + + expect(await screen.findByText(S['docsync.noFolders'])).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-005: names the store in the question it asks', async () => { + render() + + expect(await screen.findByText(S['docsync.scope.title'].replace('{provider}', 'Paperless'))).toBeInTheDocument() + expect(screen.getByText(S['docsync.scope.intro'])).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-006: the header close button leaves without binding anything', async () => { + render() + await screen.findByText('Trips') + + fireEvent.click(screen.getAllByRole('button')[0]) + + expect(onClose).toHaveBeenCalled() + expect(createLink).not.toHaveBeenCalled() + }) +}) + +describe('DocSyncScopeModal search', () => { + it('FE-DOCSYNC-SCOPE-007: a list short enough to read gets no search box', async () => { + listScopes.mockResolvedValue({ scopes: SIX }) + render() + await screen.findByText('Trips') + + expect(searchField()).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-008: one folder past the threshold the search box appears', async () => { + listScopes.mockResolvedValue({ scopes: SEVEN }) + render() + await screen.findByText('Warranty') + + expect(searchField()).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-009: typing narrows the list without going back to the store', async () => { + listScopes.mockResolvedValue({ scopes: SEVEN }) + render() + await screen.findByText('Warranty') + const before = listScopes.mock.calls.length + + fireEvent.change(searchField()!, { target: { value: ' ARCH ' } }) + + expect(screen.getByText('Archive')).toBeInTheDocument() + expect(screen.queryByText('Trips')).not.toBeInTheDocument() + // Matching is case- and whitespace-insensitive and costs no round trip. + expect(listScopes.mock.calls.length).toBe(before) + }) + + it('FE-DOCSYNC-SCOPE-010: a query nothing matches says so, and does not claim the store is empty', async () => { + listScopes.mockResolvedValue({ scopes: SEVEN }) + render() + await screen.findByText('Warranty') + + fireEvent.change(searchField()!, { target: { value: 'zzz' } }) + + expect(screen.getByText(S['docsync.scope.noMatch'])).toBeInTheDocument() + expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument() + }) +}) + +describe('DocSyncScopeModal picking an existing folder', () => { + it('FE-DOCSYNC-SCOPE-011: binds the folder that was clicked, with the defaults a new binding gets', async () => { + render() + await screen.findByText('Archive') + + fireEvent.click(rowFor('Archive')) + + await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, { + connectionId: 5, + scopeKey: 'folder-2', + remoteRootId: '2', + remoteRootPath: '/Documents/Archive', + remoteLabel: 'Archive', + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + })) + }) + + it('FE-DOCSYNC-SCOPE-012: a bound folder closes the picker', async () => { + render() + await screen.findByText('Trips') + + fireEvent.click(rowFor('Trips')) + + await waitFor(() => expect(onBound).toHaveBeenCalled()) + }) + + it('FE-DOCSYNC-SCOPE-013: a refused binding leaves the picker open so another folder can be tried', async () => { + createLink.mockRejectedValueOnce(Object.assign(new Error('nope'), { + response: { data: { error: 'forbidden' } }, + })) + render() + await screen.findByText('Trips') + + fireEvent.click(rowFor('Trips')) + + await waitFor(() => expect(createLink).toHaveBeenCalled()) + expect(onBound).not.toHaveBeenCalled() + expect(screen.getByText('Trips')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-014: while one folder is being bound nothing else can be started', async () => { + const pending = deferred<{ id: number }>() + createLink.mockReturnValueOnce(pending.promise) + render() + await screen.findByText('Trips') + + fireEvent.click(rowFor('Trips')) + + expect(rowFor('Archive')).toBeDisabled() + expect(createButton()).toBeDisabled() + + pending.resolve({ id: 1 }) + await waitFor(() => expect(onBound).toHaveBeenCalled()) + }) +}) + +describe('DocSyncScopeModal creating a folder', () => { + it('FE-DOCSYNC-SCOPE-015: the trip name is already in the field, so creating is one click', async () => { + render() + await screen.findByText('Trips') + + expect(nameField()).toHaveValue('trek-paris-42') + expect(createButton()).toBeEnabled() + }) + + it('FE-DOCSYNC-SCOPE-016: creating makes the folder and binds it in one go', async () => { + createScope.mockResolvedValue(folder(9, 'Paris 2026', '/Documents/Paris 2026')) + render() + await screen.findByText('Trips') + + fireEvent.click(createButton()) + + await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Paris 2026')) + await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ + connectionId: 5, + scopeKey: 'folder-9', + remoteRootId: '9', + remoteLabel: 'Paris 2026', + }))) + await waitFor(() => expect(onBound).toHaveBeenCalled()) + }) + + it('FE-DOCSYNC-SCOPE-021: a refused create says why instead of doing nothing', async () => { + // The create had no failure path at all: the rejection escaped the click + // handler unhandled, nothing was bound, and the dialog said nothing: the + // button blinked and the person was left guessing. Every other write in the + // hook reports, and so does this one now. + createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } }) + render() + await screen.findByText('Trips') + + fireEvent.click(createButton()) + + await waitFor(() => expect(screen.getByText(S['docsync.error.quota_exceeded'])).toBeInTheDocument()) + expect(createLink).not.toHaveBeenCalled() + expect(onBound).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-SCOPE-022: the create button comes back after a refusal', async () => { + createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } }) + render() + await screen.findByText('Trips') + + fireEvent.click(createButton()) + + await waitFor(() => expect(createButton()).toBeEnabled()) + }) + + it('FE-DOCSYNC-SCOPE-017: a blank or whitespace-only name creates nothing', async () => { + render() + await screen.findByText('Trips') + + expect(createButton()).toBeDisabled() + + fireEvent.change(nameField(), { target: { value: ' ' } }) + expect(createButton()).toBeDisabled() + + fireEvent.keyDown(nameField(), { key: 'Enter' }) + expect(createScope).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-SCOPE-018: Enter in the name field creates the typed name, not the suggested one', async () => { + createScope.mockResolvedValue(folder(9, 'Belege')) + render() + await screen.findByText('Trips') + + fireEvent.change(nameField(), { target: { value: ' Belege ' } }) + fireEvent.keyDown(nameField(), { key: 'Enter' }) + + await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Belege')) + }) +}) + +describe('DocSyncScopeModal errors', () => { + it('FE-DOCSYNC-SCOPE-019: a store that refuses the credentials says so instead of showing an empty archive', async () => { + listScopes.mockResolvedValue({ scopes: [], error: 'unauthorized' }) + render() + + expect(await screen.findByText(S['docsync.error.unauthorized'])).toBeInTheDocument() + expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-SCOPE-020: an error wins over folders the store still managed to list', async () => { + listScopes.mockResolvedValue({ scopes: THREE, error: 'scope_missing' }) + render() + + expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument() + expect(screen.queryByText('Trips')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncScopeModal.tsx b/client/src/components/Files/docsync/DocSyncScopeModal.tsx new file mode 100644 index 000000000..daa036819 --- /dev/null +++ b/client/src/components/Files/docsync/DocSyncScopeModal.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from 'react' +import { FolderPlus, Loader2, Search } from 'lucide-react' +import Modal from '../../shared/Modal' +import { useTranslation } from '../../../i18n/TranslationContext' +import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync' + +/** + * Picking the container a trip lives in. + * + * The list is filtered client-side: these are a person's own folders or tags, + * so there are tens of them, not thousands, and a round trip per keystroke + * would be slower than the filter it replaces. + * + * "Create" is a first-class action rather than a fallback at the bottom, + * because for a new trip it is the common case: there is no folder yet. + */ +export default function DocSyncScopeModal({ + connection, + providerName, + suggestedName, + sync, + onClose, + onBound, +}: { + connection: DocSyncConnection + providerName: string + /** Pre-filled name for a new container, from the trip's title. */ + suggestedName: string + sync: ReturnType + onClose: () => void + onBound: () => void +}) { + const { t } = useTranslation() + const [scopes, setScopes] = useState(null) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [newName, setNewName] = useState(suggestedName) + const [working, setWorking] = useState(null) + + // `loadScopes`, not `sync`: the hook hands back a fresh object on every + // render of the panel above, so depending on it re-listed the provider's + // folders each time anything up there changed. The callback itself is + // stable. Taken out of `sync` first, because calling it as `sync.loadScopes` + // inside the effect makes the whole object a dependency again. + const { loadScopes } = sync + useEffect(() => { + let cancelled = false + void (async () => { + const res = await loadScopes(connection.id) + if (cancelled) return + setScopes(res.scopes) + setError(res.error ?? null) + })() + return () => { cancelled = true } + }, [connection.id, loadScopes]) + + const shown = useMemo(() => { + const needle = query.trim().toLowerCase() + if (!needle || !scopes) return scopes ?? [] + return scopes.filter(s => s.label.toLowerCase().includes(needle)) + }, [scopes, query]) + + const bind = async (scope: DocSyncScope) => { + setWorking(scope.scopeKey) + const ok = await sync.createLink({ + connectionId: connection.id, + scopeKey: scope.scopeKey, + remoteRootId: scope.remoteRootId, + remoteRootPath: scope.remoteRootPath, + remoteLabel: scope.label, + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + }) + setWorking(null) + if (ok) onBound() + } + + const createAndBind = async () => { + const name = newName.trim() + if (!name) return + setWorking('__new__') + try { + // A refused create leaves nothing to bind; the hook has already put the + // reason where the dialog shows it. + const scope = await sync.createScope(connection.id, name) + if (scope) await bind(scope) + } finally { + setWorking(null) + } + } + + return ( + +
+

{t('docsync.scope.intro')}

+ +
+ {t('docsync.scope.createTitle')} +
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void createAndBind() }} + placeholder={t('docsync.newFolderPlaceholder')} + className="min-w-0 flex-1 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content ring-accent focus:outline-none focus:ring-2" + /> + +
+
+ +
+
+ {t('docsync.scope.pickTitle')} + {scopes && scopes.length > 6 && ( + + + setQuery(e.target.value)} + placeholder={t('docsync.scope.search')} + className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2" + /> + + )} +
+ + {sync.error && ( +

+ {t(`docsync.error.${sync.error}`)} +

+ )} + + {scopes === null ? ( +
+ +
+ ) : error ? ( +

+ {t(`docsync.error.${error}`)} +

+ ) : shown.length === 0 ? ( +

+ {query ? t('docsync.scope.noMatch') : t('docsync.noFolders')} +

+ ) : ( +
    + {shown.map(s => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ) +} diff --git a/client/src/components/Files/docsync/useConflicts.ts b/client/src/components/Files/docsync/useConflicts.ts new file mode 100644 index 000000000..cc1a0c4a5 --- /dev/null +++ b/client/src/components/Files/docsync/useConflicts.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react' +import { docsyncApi } from '../../../api/client' +import type { useDocSync } from './useDocSync' + +/** + * The documents that changed in both places, and the way out of that. + * + * The server has resolved conflicts since the first version and the strings + * have been in the locales just as long, but neither shell ever listed them: a + * binding reported "1 conflict" and the only place to act on it was the MCP + * tool. So a conflict was a dead end for anyone using the app. + * + * Shared between the panel and the phone sheet because both list the same rows + * with the same three answers, and because the duplication gate counts a second + * copy of it against the PR. + */ + +export interface ConflictItem { + id: number + /** The trip file's name, or the provider's when TREK never got a copy. */ + name: string + remoteName: string | null +} + +interface ItemRow { + id: number + file_name?: string | null + remote_name?: string | null +} + +export function useConflicts(tripId: number | string, sync: ReturnType, open: boolean) { + const [items, setItems] = useState(null) + const [working, setWorking] = useState(null) + + const reload = useCallback(async () => { + const rows = (await docsyncApi.items(tripId, 'conflict')) as ItemRow[] + setItems( + rows.map(r => ({ + id: r.id, + name: r.file_name || r.remote_name || '', + remoteName: r.remote_name ?? null, + })), + ) + }, [tripId]) + + // Only once the list is on screen: a trip with no conflicts should not pay + // for a request to find that out, and the count above it already says so. + useEffect(() => { + if (open) void reload() + }, [open, reload]) + + const resolve = useCallback( + async (itemId: number, keep: 'trek' | 'provider' | 'both') => { + setWorking(itemId) + try { + await sync.resolveConflict(itemId, keep) + // The row is gone from this list either way: it was resolved, or the + // call failed and `sync.error` says so. Re-reading is what tells the + // difference, rather than removing it here and hoping. + await reload() + } finally { + setWorking(null) + } + }, + [sync, reload], + ) + + return { items, working, resolve, reload } +} diff --git a/client/src/components/Files/docsync/useConnectForm.ts b/client/src/components/Files/docsync/useConnectForm.ts new file mode 100644 index 000000000..4e6d2ea6e --- /dev/null +++ b/client/src/components/Files/docsync/useConnectForm.ts @@ -0,0 +1,109 @@ +import { useState } from 'react' +import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync' + +/** + * The credential form's rules, for both shells. + * + * Which field carries the address, which one must not be drawn as a text box, + * how a label becomes a translation key, what counts as filled in when a secret + * is already stored: every one of those is a rule, and every one of them was + * wrong in the phone sheet while being right on the desktop. The phone read the + * address under `baseUrl` while the server names the field `base_url`, so every + * connection it saved carried an empty address; it drew `allow_insecure_tls` as + * a text input and always sent false; and it built label keys a different way, + * so the labels rendered as raw keys. + * + * None of that is markup, so none of it belongs in a component. + */ + +/** The field that holds the address rather than a credential. */ +export const BASE_URL_FIELD = 'base_url' + +/** Rendered as a switch beside the fields, never as one of them. */ +export const INSECURE_TLS_FIELD = 'allow_insecure_tls' + +export interface ConnectForm { + /** The fields to draw, in order, minus the ones with their own control. */ + fields: DocSyncProvider['fields'] + /** Translation key for a field's label, and for its hint where it has one. */ + labelKey: (field: DocSyncProvider['fields'][number]) => string + hintKey: (field: DocSyncProvider['fields'][number]) => string | undefined + /** What to show in a field: the typed value, else what is stored. */ + valueOf: (fieldKey: string) => string + /** A stored secret never comes back, so its box shows dots instead. */ + placeholderOf: (field: DocSyncProvider['fields'][number]) => string + setValue: (fieldKey: string, value: string) => void + baseUrl: string + credentials: Record + insecureTls: boolean + setInsecureTls: (on: boolean) => void + /** Every required field is either filled in or already stored. */ + complete: boolean + probe: () => Promise<{ connected: boolean; account?: string; error?: string }> + save: () => Promise +} + +export function useConnectForm( + provider: DocSyncProvider, + existing: DocSyncConnection | null, + sync: ReturnType, +): ConnectForm { + const [values, setValues] = useState>({}) + // Null until touched, so a connection that arrives after the form opened is + // still reflected. Seeding from `existing` at mount showed a stored "allow + // self-signed" as off and wrote that back on the next save. + const [insecure, setInsecure] = useState(null) + + const insecureTls = insecure ?? existing?.allowInsecureTls ?? false + + /** + * What a box shows. + * + * A secret shows only what was typed into it this time, never what is stored: + * a stored credential must not travel back to a browser, and a value that + * ended up in `settings` by mistake must not become the one place it leaks. + * The address lives in its own column rather than in settings, so it is read + * from there; everything else comes out of settings. + */ + const secretKeys = new Set(provider.fields.filter(f => f.secret).map(f => f.field_key)) + const valueOf = (key: string) => { + if (secretKeys.has(key)) return values[key] ?? '' + return values[key] ?? (key === BASE_URL_FIELD ? existing?.baseUrl : existing?.settings?.[key]) ?? '' + } + + const baseUrl = valueOf(BASE_URL_FIELD) + // The address stays in here as well as in its own argument: the server walks + // the provider's fields over `credentials` and would otherwise store no + // `base_url` setting. It special-cases the key when checking completeness, so + // dropping it changes nothing it needs, but it is an established request + // shape and this is not the change to alter it in. + const credentials = Object.fromEntries( + Object.entries(values).filter(([k]) => k !== INSECURE_TLS_FIELD), + ) + + const fields = provider.fields.filter(f => f.field_key !== INSECURE_TLS_FIELD) + + const complete = fields.every(f => { + if (!f.required) return true + if (f.secret) return (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key] + return valueOf(f.field_key).trim().length > 0 + }) + + return { + fields, + // The label and hint columns store a key suffix, not text. + labelKey: f => `docsync.${f.label}`, + hintKey: f => (f.hint ? `docsync.${f.hint}` : undefined), + valueOf, + placeholderOf: f => + f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || '', + setValue: (key, value) => setValues(v => ({ ...v, [key]: value })), + baseUrl, + credentials, + insecureTls, + setInsecureTls: setInsecure, + complete, + probe: () => sync.testConnection(provider.id, baseUrl, credentials, insecureTls), + save: () => sync.saveConnection(provider.id, baseUrl, credentials, insecureTls), + } +} diff --git a/client/src/components/Files/docsync/useDocSync.test.ts b/client/src/components/Files/docsync/useDocSync.test.ts new file mode 100644 index 000000000..02128c8d9 --- /dev/null +++ b/client/src/components/Files/docsync/useDocSync.test.ts @@ -0,0 +1,446 @@ +// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-026 + +/** + * The document-sync hook. + * + * Everything the panel and the phone sheet know about sync comes through here, + * so the interesting behaviour is what a person sees: holdings that arrive on a + * different route than the bindings they belong to, a dialog that must not flash + * white every time a switch is flipped, a toggle that moves before the server + * has answered, and a probe that has to produce a verdict even when the request + * itself fell over. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '../../../../tests/helpers/render' + +const providersApi = vi.fn(async (_tripId: number | string): Promise => []) +const statusApi = vi.fn(async (_tripId: number | string): Promise => ({})) +const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise => []) +const listLinksApi = vi.fn(async (_tripId: number | string): Promise => []) +const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({})) +const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ connected: true })) +const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise => ({})) +const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise => ({})) + +vi.mock('../../../api/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + docsyncApi: { + ...actual.docsyncApi, + providers: (tripId: number | string) => providersApi(tripId), + status: (tripId: number | string) => statusApi(tripId), + listConnections: (tripId: number | string) => listConnectionsApi(tripId), + listLinks: (tripId: number | string) => listLinksApi(tripId), + saveConnection: (tripId: number | string, data: unknown) => saveConnectionApi(tripId, data), + testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data), + updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch), + syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full), + }, + } +}) + +import { useDocSync, canManageDocSync, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync' + +const TRIP = 3 + +const provider = (overrides: Record = {}) => ({ + id: 'nextcloud', + name: 'Nextcloud', + description: null, + icon: 'cloud', + available: true, + fields: [], + ...overrides, +}) + +const connection = (overrides: Record = {}) => ({ + id: 9, + providerId: 'nextcloud', + baseUrl: 'https://cloud.example', + settings: {}, + secrets: {}, + allowInsecureTls: false, + lastProbeState: 'ok', + lastProbeError: null, + ...overrides, +}) + +const link = (overrides: Record = {}) => ({ + id: 1, + connectionId: 9, + providerId: 'nextcloud', + scopeKey: 'trip', + remoteLabel: 'Trip documents', + remoteRootPath: '/Trips/Rome', + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'newer', + syncEnabled: true, + lastSyncAt: null, + lastSyncState: 'idle', + lastSyncError: null, + webhookUrl: null, + ...overrides, +}) + +/** A promise this test settles by hand, to hold a request open. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +/** Mount the hook and record `loading` on every render it goes through. */ +function mount(tripId: number | string = TRIP, enabled = true) { + const seenLoading: boolean[] = [] + const view = renderHook(() => { + const hook = useDocSync(tripId, enabled) + seenLoading.push(hook.loading) + return hook + }) + return { ...view, seenLoading } +} + +/** Mount and wait until the first load is through. */ +async function mountLoaded(tripId: number | string = TRIP) { + const view = mount(tripId) + await waitFor(() => expect(view.result.current.loading).toBe(false)) + return view +} + +beforeEach(() => { + vi.clearAllMocks() + providersApi.mockResolvedValue([provider()]) + statusApi.mockResolvedValue({ items: {}, links: [] }) + listConnectionsApi.mockResolvedValue([connection()]) + listLinksApi.mockResolvedValue([link()]) + saveConnectionApi.mockResolvedValue({}) + testConnectionApi.mockResolvedValue({ connected: true }) + updateLinkApi.mockResolvedValue({}) + syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) +}) + +describe('useDocSync initial load', () => { + it('FE-DOCSYNC-HOOK-001: joins the status route holdings onto the binding they belong to', async () => { + const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 } + listLinksApi.mockResolvedValue([link()]) + statusApi.mockResolvedValue({ items: { conflict: 2, pending: 1 }, links: [{ id: 1, holdings }] }) + + const { result } = await mountLoaded() + + expect(result.current.links).toHaveLength(1) + expect(result.current.links[0].holdings).toEqual(holdings) + expect(result.current.itemCounts).toEqual({ conflict: 2, pending: 1 }) + expect(result.current.connections).toEqual([connection()]) + }) + + it('FE-DOCSYNC-HOOK-002: a binding the status route never mentions does not inherit another binding counts', async () => { + const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 } + listLinksApi.mockResolvedValue([link(), link({ id: 2, remoteLabel: 'Receipts' })]) + statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, holdings }] }) + + const { result } = await mountLoaded() + + expect(result.current.links[0].holdings).toEqual(holdings) + expect(result.current.links[1].holdings).toBeUndefined() + }) + + it('FE-DOCSYNC-HOOK-025: carries the provider switch from the status route onto its binding', async () => { + listLinksApi.mockResolvedValue([link(), link({ id: 2 }), link({ id: 3 })]) + statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, providerOff: true }, { id: 2, providerOff: false }] }) + + const { result } = await mountLoaded() + + // The third is one the status route never mentions: running, not paused. + expect(result.current.links.map(l => l.providerOff)).toEqual([true, false, false]) + }) + + it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => { + statusApi.mockResolvedValue({}) + + const { result } = await mountLoaded() + + expect(result.current.itemCounts).toEqual({}) + expect(result.current.links[0].holdings).toBeUndefined() + }) + + it('FE-DOCSYNC-HOOK-004: offers only the providers this instance can actually use', async () => { + providersApi.mockResolvedValue([ + provider(), + provider({ id: 'gdrive', name: 'Google Drive', available: false }), + ]) + + const { result } = await mountLoaded() + + expect(result.current.providers.map(p => p.id)).toEqual(['nextcloud']) + }) + + it('FE-DOCSYNC-HOOK-005: a failed load leaves nothing to configure instead of blowing up', async () => { + // The addon was switched off, or this member lost access to the trip. + providersApi.mockRejectedValue(Object.assign(new Error('forbidden'), { response: { status: 403 } })) + + const { result } = await mountLoaded() + + expect(result.current.providers).toEqual([]) + expect(result.current.links).toEqual([]) + expect(result.current.error).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it('FE-DOCSYNC-HOOK-023: a reload that finds the addon gone drops the bindings it had loaded', async () => { + statusApi.mockResolvedValue({ items: { conflict: 1 }, links: [] }) + const { result } = await mountLoaded() + expect(result.current.links).toHaveLength(1) + + providersApi.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } })) + await act(async () => { await result.current.load() }) + + expect(result.current.providers).toEqual([]) + expect(result.current.connections).toEqual([]) + expect(result.current.links).toEqual([]) + expect(result.current.itemCounts).toEqual({}) + expect(result.current.error).toBeNull() + }) + + it('FE-DOCSYNC-HOOK-006: asks the server nothing while the panel is closed', async () => { + const { result } = mount(TRIP, false) + + await act(async () => { await Promise.resolve() }) + + expect(providersApi).not.toHaveBeenCalled() + expect(listLinksApi).not.toHaveBeenCalled() + expect(result.current.loading).toBe(false) + }) +}) + +describe('useDocSync spinner', () => { + it('FE-DOCSYNC-HOOK-007: shows the spinner for the first load, which has nothing to show yet', async () => { + const gate = deferred() + listLinksApi.mockReturnValueOnce(gate.promise) + + const { result, seenLoading } = mount() + + expect(result.current.loading).toBe(true) + expect(seenLoading[seenLoading.length - 1]).toBe(true) + + await act(async () => { gate.resolve([link()]) }) + await waitFor(() => expect(result.current.loading).toBe(false)) + }) + + it('FE-DOCSYNC-HOOK-008: never raises the spinner again while a mutation refetches', async () => { + // Regression guard: a refetch that flips `loading` swaps the whole dialog + // for a spinner and back, so flipping one switch flashes the panel white. + const { result, seenLoading } = await mountLoaded() + const gate = deferred() + listLinksApi.mockReturnValueOnce(gate.promise) + seenLoading.length = 0 + + let pending!: Promise + await act(async () => { pending = result.current.updateLink(1, { syncEnabled: false }) }) + expect(result.current.loading).toBe(false) + + await act(async () => { + gate.resolve([link({ syncEnabled: false })]) + await pending + }) + + // the refetch really happened... + expect(listLinksApi).toHaveBeenCalledTimes(2) + // ...and the panel never went blank for it + expect(seenLoading).not.toContain(true) + expect(result.current.loading).toBe(false) + }) +}) + +describe('useDocSync updateLink', () => { + it('FE-DOCSYNC-HOOK-009: moves the switch before the server has answered', async () => { + const { result } = await mountLoaded() + const gate = deferred() + updateLinkApi.mockReturnValueOnce(gate.promise) + + let pending!: Promise + await act(async () => { pending = result.current.updateLink(1, { direction: 'pull' }) }) + + expect(result.current.links[0].direction).toBe('pull') + expect(updateLinkApi).toHaveBeenCalledWith(TRIP, 1, { direction: 'pull' }) + + await act(async () => { gate.resolve({}); await pending }) + }) + + it('FE-DOCSYNC-HOOK-010: reloads afterwards, so the server row is what stays on screen', async () => { + const { result } = await mountLoaded() + listLinksApi.mockResolvedValue([link({ direction: 'pull', lastSyncState: 'ok', remoteLabel: 'Trip documents (moved)' })]) + + await act(async () => { await result.current.updateLink(1, { direction: 'pull' }) }) + + expect(listLinksApi).toHaveBeenCalledTimes(2) + expect(result.current.links[0].remoteLabel).toBe('Trip documents (moved)') + expect(result.current.links[0].lastSyncState).toBe('ok') + }) + + it('FE-DOCSYNC-HOOK-011: a rejected update does not leave the optimistic value standing', async () => { + const { result } = await mountLoaded() + updateLinkApi.mockRejectedValueOnce({ response: { data: { error: 'forbidden' } } }) + listLinksApi.mockResolvedValueOnce([link({ direction: 'both' })]) + + let ok: boolean | undefined + await act(async () => { ok = await result.current.updateLink(1, { direction: 'pull' }) }) + + // Reported rather than thrown: both shells call this as `void`, so a + // rejection reached nobody. The reload puts the server's answer back and + // the reason stays on screen. + expect(ok).toBe(false) + expect(result.current.links[0].direction).toBe('both') + expect(result.current.error).toBe('forbidden') + }) +}) + +describe('useDocSync testConnection', () => { + it('FE-DOCSYNC-HOOK-012: hands the probe verdict back to the form', async () => { + testConnectionApi.mockResolvedValue({ connected: true, account: 'owner@cloud.example' }) + const { result } = await mountLoaded() + + let verdict: { connected: boolean; account?: string; error?: string } | undefined + await act(async () => { + verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', { token: 'abc' }, false) + }) + + expect(verdict).toEqual({ connected: true, account: 'owner@cloud.example' }) + expect(testConnectionApi).toHaveBeenCalledWith(TRIP, { + providerId: 'nextcloud', + baseUrl: 'https://cloud.example', + credentials: { token: 'abc' }, + allowInsecureTls: false, + }) + }) + + it('FE-DOCSYNC-HOOK-013: a request that throws still produces a verdict the form can render', async () => { + testConnectionApi.mockRejectedValueOnce({ response: { data: { error: 'docsync.unreachable' } } }) + const { result } = await mountLoaded() + + let verdict: { connected: boolean; error?: string } | undefined + await act(async () => { + verdict = await result.current.testConnection('nextcloud', 'https://typo.example', {}, false) + }) + + expect(verdict).toEqual({ connected: false, error: 'docsync.unreachable' }) + }) + + it('FE-DOCSYNC-HOOK-014: an error with no message of its own falls back to a generic one', async () => { + testConnectionApi.mockRejectedValueOnce(new Error('socket hang up')) + const { result } = await mountLoaded() + + let verdict: { connected: boolean; error?: string } | undefined + await act(async () => { + verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) + }) + + expect(verdict).toEqual({ connected: false, error: 'unknown' }) + }) + + it('FE-DOCSYNC-HOOK-015: marks the probe busy while it runs and clears it when it lands', async () => { + const { result } = await mountLoaded() + const gate = deferred() + testConnectionApi.mockReturnValueOnce(gate.promise) + + let pending!: Promise + await act(async () => { pending = result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) }) + expect(result.current.busy).toBe('test') + + await act(async () => { gate.resolve({ connected: true }); await pending }) + expect(result.current.busy).toBeNull() + }) +}) + +describe('useDocSync run and save feedback', () => { + it('FE-DOCSYNC-HOOK-016: remembers what the last run moved, per binding', async () => { + syncNowApi.mockResolvedValue({ state: 'ok', pulled: 4, pushed: 2, conflicts: 0, missing: 0 }) + const { result } = await mountLoaded() + + await act(async () => { await result.current.syncNow(1) }) + + expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, false) + // What a run moved is no longer kept: the flow bar shows standing holdings, + // which is the question somebody opening the dialog actually has. The run's + // own numbers were only ever on screen for the moment after pressing it. + expect(result.current.links).toHaveLength(1) + }) + + it('FE-DOCSYNC-HOOK-017: a refused run says so instead of vanishing into a void call', async () => { + // syncNow is called as `void sync.syncNow(...)` from both shells, so a + // rejection that escaped the hook reached nobody. An orphaned binding + // answers 409 here, and the person pressing the button deserves the reason. + syncNowApi.mockRejectedValueOnce({ response: { data: { error: 'link_orphaned' } } }) + const { result } = await mountLoaded() + + let outcome: unknown + await act(async () => { outcome = await result.current.syncNow(1) }) + + expect(outcome).toBeNull() + expect(result.current.error).toBe('link_orphaned') + }) + + it('FE-DOCSYNC-HOOK-026: a run refused because an admin switched the provider off keeps the code', async () => { + // Both shells render the error as docsync.error., so the code has to + // arrive untouched for the sentence to be the right one. + syncNowApi.mockRejectedValueOnce({ response: { status: 409, data: { error: 'provider_disabled' } } }) + const { result } = await mountLoaded() + + await act(async () => { await result.current.syncNow(1) }) + + expect(result.current.error).toBe('provider_disabled') + }) + + it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => { + saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } }) + const { result } = await mountLoaded() + + let ok: boolean | undefined + await act(async () => { + ok = await result.current.saveConnection('nextcloud', 'https://cloud.example', { token: 'nope' }, false) + }) + + expect(ok).toBe(false) + expect(result.current.error).toBe('docsync.badCredentials') + expect(result.current.busy).toBeNull() + // nothing was refetched, because nothing changed server-side + expect(listLinksApi).toHaveBeenCalledTimes(1) + }) +}) + +describe('storeName', () => { + it('FE-DOCSYNC-HOOK-024: prefers the provider list, then the name on the link, then the raw id', () => { + const bound = link() as DocSyncLink + const listed = [provider({ name: 'Nextcloud Hub' }) as DocSyncProvider] + + expect(storeName({ ...bound, providerName: 'Nextcloud' }, listed)).toBe('Nextcloud Hub') + // Switched off by an admin, so the providers route no longer lists it. + expect(storeName({ ...bound, providerName: 'Nextcloud' }, [])).toBe('Nextcloud') + expect(storeName(bound, [])).toBe('nextcloud') + }) +}) + +describe('canManageDocSync', () => { + it('FE-DOCSYNC-HOOK-019: the trip owner may repoint a binding', () => { + expect(canManageDocSync({ id: 4, role: 'user' }, { user_id: 4 })).toBe(true) + }) + + it('FE-DOCSYNC-HOOK-020: an instance admin may, even on a trip that is not theirs', () => { + expect(canManageDocSync({ id: 12, role: 'admin' }, { user_id: 4 })).toBe(true) + }) + + it('FE-DOCSYNC-HOOK-021: an ordinary member may not point the owner archive somewhere else', () => { + expect(canManageDocSync({ id: 12, role: 'user' }, { user_id: 4 })).toBe(false) + expect(canManageDocSync({ id: 12, role: 'user' }, null)).toBe(false) + expect(canManageDocSync({ id: 12, role: 'user' }, {})).toBe(false) + }) + + it('FE-DOCSYNC-HOOK-022: nobody signed in, nobody may manage', () => { + expect(canManageDocSync(null, { user_id: 4 })).toBe(false) + expect(canManageDocSync(undefined, { user_id: 4 })).toBe(false) + // ids arrive as strings on some routes and as numbers on others + expect(canManageDocSync({ id: '4', role: 'user' }, { user_id: 4 })).toBe(true) + }) +}) diff --git a/client/src/components/Files/docsync/useDocSync.ts b/client/src/components/Files/docsync/useDocSync.ts new file mode 100644 index 000000000..7d2732df1 --- /dev/null +++ b/client/src/components/Files/docsync/useDocSync.ts @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { docsyncApi } from '../../../api/client' +import { useServerPing } from '../../../sync/useServerPing' + +/** + * All document-sync state and actions, in one hook. + * + * Deliberately a hook rather than logic inside the panel: the phone shell is a + * separate component tree (client/src/mobile/screens/trip/...), and duplicating + * this across both would land on SonarCloud's 3% duplication budget the way the + * photo provider section already does. `useDawarichConnection` states the same + * reason for the same problem. + */ + +export interface DocSyncField { + field_key: string + label: string + input_type: string + placeholder: string | null + hint: string | null + required: boolean + secret: boolean +} + +export interface DocSyncProvider { + id: string + name: string + description: string | null + icon: string + available: boolean + fields: DocSyncField[] +} + +export interface DocSyncConnection { + id: number + providerId: string + baseUrl: string + settings: Record + secrets: Record + allowInsecureTls: boolean + lastProbeState: string + lastProbeError: string | null +} + +export interface DocSyncScope { + scopeKey: string + label: string + remoteRootId: string | null + remoteRootPath: string | null +} + +export interface DocSyncLink { + id: number + connectionId: number + providerId: string + scopeKey: string + remoteLabel: string + remoteRootPath: string | null + direction: 'both' | 'pull' | 'push' + deletePolicy: 'unlink' | 'trash' + conflictPolicy: string + syncEnabled: boolean + lastSyncAt: string | null + lastSyncState: string + lastSyncError: string | null + webhookUrl: string | null + /** + * The store's display name as the server knows it. Carried on the link + * because the providers route only lists what an admin has switched on, and + * a binding outlives its provider being switched off. + */ + providerName?: string + /** Standing counts per side, from the status route. */ + holdings?: { inTrek: number; atProvider: number; paired: number; missing: number } + /** + * An admin has switched this binding's provider off. The server leaves the + * binding as it was, so `lastSyncState` still describes the last run and + * this is what says why nothing moves now. From the status route. + */ + providerOff?: boolean +} + +/** + * What to call the store a binding points at. + * + * One lookup for both shells, so the list row, the card header and the flow + * summary cannot name the same store three different ways. The providers list + * wins because the rest of the dialog uses its names; the link's own name + * covers a provider that has since been switched off. + */ +export function storeName(link: DocSyncLink, providers: readonly DocSyncProvider[]): string { + return providers.find(p => p.id === link.providerId)?.name || link.providerName || link.providerId +} + +/** + * The line a binding card shows under its header, or null when there is + * nothing to say. + * + * Both shells render it, so a phone and a laptop cannot disagree about why a + * binding stands still. A provider an admin switched off comes first: the + * server leaves the binding's state as the last run wrote it, which would + * otherwise report an old failure, or nothing, for a binding that is paused. + */ +export function bindingNotice( + link: Pick, + t: (key: string) => string, +): string | null { + if (link.providerOff) return t('docsync.error.provider_disabled') + if (link.lastSyncState === 'ok' || link.lastSyncState === 'never') return null + const state = t(`docsync.linkState.${link.lastSyncState}`) + return link.lastSyncError ? `${state} · ${t(`docsync.error.${link.lastSyncError}`)}` : state +} + +/** + * Whether this person may change a sync binding. + * + * Only the trip owner: the credential a binding stores usually reaches that + * person's entire document archive, so letting any member repoint it would + * share a folder the owner never chose to share. Instance admins are included + * because they already override every other permission check in the client. + * + * Shared by both shells rather than written out twice: the desktop file + * manager and the phone sheet must not be able to drift on who may do this. + */ +export function canManageDocSync( + user: { id?: number | string; role?: string } | null | undefined, + trip: { user_id?: number | string } | null | undefined, +): boolean { + if (!user) return false + return user.role === 'admin' || Number(trip?.user_id) === Number(user.id) +} + +export function useDocSync(tripId: number | string, enabled: boolean) { + const [providers, setProviders] = useState([]) + const [connections, setConnections] = useState([]) + const [links, setLinks] = useState([]) + const [itemCounts, setItemCounts] = useState>({}) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + + /** + * Whether the first load is through. + * + * Every mutation refreshes, and a refresh that flips `loading` swaps the whole + * dialog for a spinner and back: a white flash on something as small as + * toggling a direction. Only the first load has nothing to show yet. + */ + const loadedOnce = useRef(false) + + /** + * Which load is the current one. + * + * Four requests go out per load and several loads overlap: every mutation + * starts one, and so does every ping. Without a generation stamp the slowest + * response wins, which on a binding somebody just changed means the screen + * settles on the state from before the change. + */ + const generation = useRef(0) + /** Whether the last failure came from loading rather than from a write. */ + const loadFailed = useRef(false) + + const load = useCallback(async () => { + if (!enabled) return + const mine = ++generation.current + if (!loadedOnce.current) setLoading(true) + try { + const [p, c, l, s] = await Promise.all([ + docsyncApi.providers(tripId), + docsyncApi.listConnections(tripId), + docsyncApi.listLinks(tripId), + docsyncApi.status(tripId), + ]) + if (mine !== generation.current) return + setProviders(p as DocSyncProvider[]) + setConnections(c as DocSyncConnection[]) + // The status route carries the holdings and the provider switch; the + // links route does not, so the two are merged here rather than asking + // every caller to join them. + const status = s as { items?: Record; links?: DocSyncLink[] } + const statusById = new Map((status.links ?? []).map(x => [x.id, x])) + setLinks((l as DocSyncLink[]).map(x => ({ + ...x, + holdings: statusById.get(x.id)?.holdings, + providerOff: statusById.get(x.id)?.providerOff === true, + }))) + setItemCounts(status.items || {}) + // Only a load failure is cleared here. A write that just failed reloads to + // put the server's answer back on screen, and clearing unconditionally + // wiped the reason it had set a moment earlier. + setError(prev => (prev !== null && loadFailed.current ? null : prev)) + loadFailed.current = false + loadedOnce.current = true + } catch (e: unknown) { + // A 403 or 404 really does mean "nothing to configure here" (the addon is + // off or this person lost access), and blanking the panel is the honest + // answer. Anything else is a failure to say so: blanking on a dropped + // connection told the user their provider list was empty. + if (mine !== generation.current) return + const status = (e as { response?: { status?: number } })?.response?.status + loadFailed.current = true + if (status === 403 || status === 404) { + // The bindings go too. Both shells keep a binding on screen with no + // provider left, so emptying the providers alone blanked nothing. + setProviders([]) + setConnections([]) + setLinks([]) + setItemCounts({}) + setError(null) + } else { + setError(readError(e)) + } + } finally { + if (mine === generation.current) setLoading(false) + } + }, [tripId, enabled]) + + useEffect(() => { void load() }, [load]) + + // A run started by another member moves the same documents and the same + // counts. The event is a ping rather than a payload: the numbers it would + // carry are per-binding server state, and reading them back is the only way + // this panel and that member's panel end up saying the same thing. + useServerPing('docsync:changed', enabled, load) + + const connectionFor = useCallback( + (providerId: string) => connections.find(c => c.providerId === providerId) || null, + [connections], + ) + + const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record, allowInsecureTls: boolean) => { + setBusy('save') + setError(null) + try { + await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + + /** + * Probe without saving. Always resolves to a verdict object: the route + * answers 200 even for an unreachable instance, because a form showing a typo + * needs a field to render, not an exception. + */ + const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record, allowInsecureTls: boolean) => { + setBusy('test') + try { + return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as { + connected: boolean; account?: string; error?: string; detail?: string + } + } catch (e: unknown) { + return { connected: false, error: readError(e) } + } finally { + setBusy(null) + } + }, [tripId]) + + const loadScopes = useCallback(async (connectionId: number, q?: string) => { + const res = await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string } + return res + }, [tripId]) + + /** + * Make a container at the provider. + * + * Reports failure the same way every other write here does. A rejected + * create used to escape as an unhandled rejection, so the button blinked and + * nothing happened and nothing was said. Null rather than a throw, because + * the caller's next step is to bind what came back, and there is nothing to + * bind. + */ + const createScope = useCallback(async (connectionId: number, name: string): Promise => { + setBusy('scope') + setError(null) + try { + return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope + } catch (e: unknown) { + setError(readError(e)) + return null + } finally { + setBusy(null) + } + }, [tripId]) + + const createLink = useCallback(async (payload: Record) => { + setBusy('link') + setError(null) + try { + await docsyncApi.createLink(tripId, payload) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + + /** + * Change one binding, optimistically. + * + * The reload in `finally` puts the server's answer back either way, so a + * refused change corrects itself on screen, but it used to do that in + * silence, with the rejection escaping into a `void` call site. The switch + * simply flipped back and nobody said why. + */ + const updateLink = useCallback(async (linkId: number, patch: Record) => { + setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l))) + setError(null) + try { + await docsyncApi.updateLink(tripId, linkId, patch) + await load() + return true + } catch (e: unknown) { + // Reload first, so the optimistic value is corrected, then say why. + // The other way round the reload cleared the message again. + await load() + setError(readError(e)) + return false + } + }, [tripId, load]) + + const removeLink = useCallback(async (linkId: number) => { + setBusy('link') + setError(null) + try { + await docsyncApi.deleteLink(tripId, linkId) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + + const syncNow = useCallback(async (linkId: number, full = false) => { + setBusy(`sync-${linkId}`) + setError(null) + try { + const res = await docsyncApi.syncNow(tripId, linkId, full) as { + state: string; pulled: number; pushed: number; conflicts: number; missing: number + } + await load() + return res + } catch (e: unknown) { + // A refused run is a thing the person asked for and did not get (an + // orphaned binding answers 409 here), so it has to be said out loud + // rather than escaping into the `void` at the call site. After the + // reload, which would otherwise clear the message. + await load() + setError(readError(e)) + return null + } finally { + setBusy(null) + } + }, [tripId, load]) + + const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => { + await docsyncApi.resolve(tripId, itemId, keep) + await load() + }, [tripId, load]) + + /** Providers the admin switched on that also have a working adapter. */ + const usableProviders = useMemo(() => providers.filter(p => p.available), [providers]) + + return { + providers: usableProviders, + connections, links, itemCounts, + loading, busy, error, + connectionFor, load, + saveConnection, testConnection, + loadScopes, createScope, + createLink, updateLink, removeLink, syncNow, resolveConflict, + } +} + +/** + * The error code a failed call carries, or `unknown`. + * + * Both shells render this as `docsync.error.`, so anything that is not + * one of the codes reaches the screen as a raw translation key, and the server + * answers plenty of routes with a sentence ("Link not found") rather than a + * code. A general message is worse than a specific one and far better than + * `docsync.error.Link not found`. + */ +function readError(e: unknown): string { + const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response + const raw = res?.data?.error || res?.data?.message || '' + // A code passes through: the sync codes and the handful of keys the routes + // answer with are all of this shape. A sentence does not: several routes + // answer with prose ("Link not found"), and both shells render this as + // `docsync.error.`, so that reached the screen as a raw translation + // key. A general message is worse than a specific one and far better than + // `docsync.error.Link not found`. + return /^[a-z0-9_.]+$/i.test(raw) ? raw : 'unknown' +} diff --git a/client/src/components/Files/docsync/useDocSyncOffered.test.ts b/client/src/components/Files/docsync/useDocSyncOffered.test.ts new file mode 100644 index 000000000..5e984cdce --- /dev/null +++ b/client/src/components/Files/docsync/useDocSyncOffered.test.ts @@ -0,0 +1,190 @@ +// FE-DOCSYNC-OFFER-001 to FE-DOCSYNC-OFFER-013 + +/** + * Whether the Files screen offers document sync. + * + * The providers ship switched off, so the rule is what keeps an upgraded + * instance from growing a button into an empty dialog on every trip: a bound + * trip shows it to everybody, an unbound one only to somebody who could bind + * it, and only once the admin has switched a provider on. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { http, HttpResponse } from 'msw' +import { act, renderHook, waitFor } from '../../../../tests/helpers/render' +import { server } from '../../../../tests/helpers/msw/server' +import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore' +import { setForcedOffline } from '../../../sync/networkMode' +import { useDocSyncOffered } from './useDocSyncOffered' + +const listeners = new Set<(e: Record) => void>() + +vi.mock('../../../api/websocket', async (importOriginal) => ({ + ...(await importOriginal()), + addListener: (fn: (e: Record) => void) => { listeners.add(fn) }, + removeListener: (fn: (e: Record) => void) => { listeners.delete(fn) }, +})) + +/** Deliver an event the way the websocket layer would. */ +function emit(type: string): void { + for (const fn of [...listeners]) fn({ type }) +} + +const TRIP = 4 + +let providerCalls = 0 +let linkCalls = 0 + +function routes({ providers = [] as unknown[], links = [] as unknown[], linksStatus = 200 } = {}) { + server.use( + http.get('/api/trips/:tripId/docsync/providers', () => { + providerCalls += 1 + return HttpResponse.json(providers) + }), + http.get('/api/trips/:tripId/docsync/links', () => { + linkCalls += 1 + return linksStatus === 200 + ? HttpResponse.json(links) + : HttpResponse.json({ error: 'nope' }, { status: linksStatus }) + }), + ) +} + +const paperless = { id: 'paperless', name: 'Paperless-ngx', available: true } +const binding = { id: 1, providerId: 'paperless' } + +beforeEach(() => { + providerCalls = 0 + linkCalls = 0 + listeners.clear() + useDocSyncOfferStore.setState({ bound: {}, providers: null }) +}) + +afterEach(() => setForcedOffline(false)) + +describe('useDocSyncOffered', () => { + it('FE-DOCSYNC-OFFER-001: somebody who may bind the trip sees it once a provider is on, without asking about bindings', async () => { + routes({ providers: [paperless] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, true)) + + await waitFor(() => expect(result.current).toBe(true)) + expect(providerCalls).toBe(1) + expect(linkCalls).toBe(0) + }) + + it('FE-DOCSYNC-OFFER-002: with every provider off and nothing bound, nobody sees it', async () => { + routes({ providers: [] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, true)) + + await waitFor(() => expect(linkCalls).toBe(1)) + expect(result.current).toBe(false) + }) + + it('FE-DOCSYNC-OFFER-003: a provider row without an adapter does not count as offered', async () => { + routes({ providers: [{ ...paperless, available: false }] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, true)) + + await waitFor(() => expect(linkCalls).toBe(1)) + expect(result.current).toBe(false) + }) + + it('FE-DOCSYNC-OFFER-004: a member sees a bound trip, and is never asked about providers', async () => { + routes({ providers: [paperless], links: [binding] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + + await waitFor(() => expect(result.current).toBe(true)) + expect(providerCalls).toBe(0) + }) + + it('FE-DOCSYNC-OFFER-005: a member does not see an unbound trip, providers or not', async () => { + routes({ providers: [paperless], links: [] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + + await waitFor(() => expect(linkCalls).toBe(1)) + expect(result.current).toBe(false) + }) + + it('FE-DOCSYNC-OFFER-006: a bound trip keeps the button after the admin switched every provider off', async () => { + routes({ providers: [], links: [binding] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, true)) + + await waitFor(() => expect(result.current).toBe(true)) + }) + + it('FE-DOCSYNC-OFFER-007: the last answer renders at once on the next visit, then is checked again', async () => { + routes({ links: [binding] }) + const first = renderHook(() => useDocSyncOffered(TRIP, false)) + await waitFor(() => expect(first.result.current).toBe(true)) + first.unmount() + + const second = renderHook(() => useDocSyncOffered(TRIP, false)) + expect(second.result.current).toBe(true) + await waitFor(() => expect(linkCalls).toBe(2)) + }) + + it('FE-DOCSYNC-OFFER-008: offline it asks nothing and keeps what it knew, and asks again once back', async () => { + routes({ links: [] }) + useDocSyncOfferStore.setState({ bound: { [TRIP]: true } }) + act(() => setForcedOffline(true)) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + + expect(result.current).toBe(true) + expect(linkCalls).toBe(0) + + act(() => setForcedOffline(false)) + await waitFor(() => expect(result.current).toBe(false)) + expect(linkCalls).toBe(1) + }) + + it('FE-DOCSYNC-OFFER-009: a request that never got an answer leaves the last one standing', async () => { + server.use(http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.error())) + useDocSyncOfferStore.setState({ bound: { [TRIP]: true } }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + + await act(() => useDocSyncOfferStore.getState().refresh(TRIP, false)) + expect(result.current).toBe(true) + }) + + it('FE-DOCSYNC-OFFER-010: the Documents addon switched off is a real no', async () => { + routes({ linksStatus: 403 }) + useDocSyncOfferStore.setState({ bound: { [TRIP]: true } }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + + await waitFor(() => expect(result.current).toBe(false)) + }) + + it('FE-DOCSYNC-OFFER-011: a refresh asked for while offline sends nothing and changes nothing', async () => { + routes({ providers: [paperless] }) + useDocSyncOfferStore.setState({ providers: false }) + setForcedOffline(true) + + await useDocSyncOfferStore.getState().refresh(TRIP, true) + + expect(providerCalls).toBe(0) + expect(useDocSyncOfferStore.getState().providers).toBe(false) + }) + + it('FE-DOCSYNC-OFFER-012: a member on the Files tab gets the button when the owner binds the trip', async () => { + routes({ links: [] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, false)) + await waitFor(() => expect(linkCalls).toBe(1)) + expect(result.current).toBe(false) + + // The owner binds it: nothing remounts here, the server only pings. + routes({ links: [binding] }) + act(() => emit('docsync:changed')) + + await waitFor(() => expect(result.current).toBe(true)) + expect(linkCalls).toBe(2) + }) + + it('FE-DOCSYNC-OFFER-013: the button goes when the last binding does, with every provider off', async () => { + routes({ providers: [], links: [binding] }) + const { result } = renderHook(() => useDocSyncOffered(TRIP, true)) + await waitFor(() => expect(result.current).toBe(true)) + + routes({ providers: [], links: [] }) + act(() => emit('docsync:changed')) + + await waitFor(() => expect(result.current).toBe(false)) + }) +}) diff --git a/client/src/components/Files/docsync/useDocSyncOffered.ts b/client/src/components/Files/docsync/useDocSyncOffered.ts new file mode 100644 index 000000000..7d73e70be --- /dev/null +++ b/client/src/components/Files/docsync/useDocSyncOffered.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { useNetworkMode } from '../../../hooks/useNetworkMode' +import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore' +import { useServerPing } from '../../../sync/useServerPing' + +/** + * Whether this trip's Files screen shows the "Document sync" button. + * + * One hook for both shells, so the toolbar and the phone header cannot drift + * on who sees it. The last known answer renders straight away; the server is + * asked again when the screen mounts and when the connection comes back, and + * never while offline, where the button keeps whatever it last knew. + * + * It is also asked on every `docsync:changed`, which the server sends when a + * binding comes or goes. A member can sit on the Files tab while the owner + * binds the trip, and on desktop that tab may never remount before a reload. + */ +export function useDocSyncOffered(tripId: number | string, canManage: boolean): boolean { + const offered = useDocSyncOfferStore( + s => s.bound[String(tripId)] === true || (canManage && s.providers === true), + ) + const refresh = useDocSyncOfferStore(s => s.refresh) + const { offline } = useNetworkMode() + + useEffect(() => { + if (!offline) void refresh(tripId, canManage) + }, [tripId, canManage, offline, refresh]) + + // Off while offline: the effect above already asks on the way back, and the + // ping's own return-to-online refetch would only ask a second time. + useServerPing('docsync:changed', !offline, () => refresh(tripId, canManage)) + + return offered +} diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 87a675cf9..9970931ba 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -6,6 +6,9 @@ import { filesApi } from '../../api/client' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import { useAuthStore } from '../../store/authStore' +import { canManageDocSync } from './docsync/useDocSync' +import { useDocSyncOffered } from './docsync/useDocSyncOffered' import { getAuthUrl } from '../../api/authUrl' import { isImage, isMedia, isWalletPass } from './FileManager.helpers' import { openFile as openFileInTab } from '../../utils/fileDownload' @@ -30,6 +33,7 @@ export interface FileManagerProps { */ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) + const [showDocSync, setShowDocSync] = useState(false) const [filterType, setFilterType] = useState('all') const [lightboxIndex, setLightboxIndex] = useState(null) const [showTrash, setShowTrash] = useState(false) @@ -38,6 +42,9 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place const toast = useToast() const can = useCanDo() const trip = useTripStore((s) => s.trip) + const currentUser = useAuthStore((s) => s.user) + const canManageSync = canManageDocSync(currentUser, trip) + const docSyncOffered = useDocSyncOffered(tripId, canManageSync) const { t, locale } = useTranslation() const loadTrash = useCallback(async () => { @@ -210,6 +217,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId, getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete, handleAssign, mediaFiles, openFile, + showDocSync, setShowDocSync, canManageSync, docSyncOffered, } } diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index d44923dbe..3a35c46d3 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -1180,6 +1180,31 @@ describe('MapView photo thumbnails', () => { expect(thumbCallbacks.size).toBe(0) expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled() }) + + it('FE-COMP-MAPVIEW-080: an uploaded photo fills its marker whatever its proportions', () => { + // Leaflet's marker pane sets `width: auto` on every img in it, so a photo sized + // by attributes was drawn at its full pixel size and the circle only ever showed + // the transparent corner of it, over the category colour. + render() + const holder = document.createElement('div') + holder.innerHTML = iconHtmlOf(screen.getAllByTestId('marker')[0]) + const img = holder.querySelector('img')! + + expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg') + expect(img.style.width).toBe('100%') + expect(img.style.height).toBe('100%') + }) + + it('FE-COMP-MAPVIEW-081: taking the upload off a place asks for its auto photo again, without a reload', () => { + const place = buildMapPlace({ id: 28, lat: 48, lng: 2, google_place_id: 'gp-28', name: 'Tower', image_url: '/uploads/places/own.jpg' }) + const { rerender } = render() + expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled() + + rerender() + + expect(vi.mocked(photoService.fetchPhoto)).toHaveBeenCalledWith('gp-28', 'gp-28', 48, 2, 'Tower') + expect(thumbCallbacks.has('gp-28')).toBe(true) + }) }) // The marker HTML is a hand-built string handed to L.divIcon, i.e. innerHTML. diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 169f8bfcb..82332b5bf 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -195,7 +195,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { box-shadow:${shadow}; overflow:hidden;background:${bgColor}; "> - + ${markerPhotoHtml(place.image_url)}
${badgeHtml}
`, @@ -543,7 +543,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle // Module-level photo cache shared with PlaceAvatar import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' -import { isCustomPlaceImage, photoCacheKey } from './placePhoto' +import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto' import { useAuthStore } from '../../store/authStore' import { useGeolocation } from '../../hooks/useGeolocation' import LocationButton from './LocationButton' @@ -838,7 +838,7 @@ export const MapView = memo(function MapView({ const pendingThumbsRef = useRef>({}) const thumbRafRef = useRef(null) - const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) + const photoSources = useMemo(() => photoSourcesKey(places), [places]) // Flattened [lat,lng] points of the selected day's route, so the bounds fit can // include the full polyline once it has been computed. const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route]) @@ -895,7 +895,7 @@ export const MapView = memo(function MapView({ thumbRafRef.current = null } } - }, [placeIds, placesPhotosEnabled]) + }, [photoSources, placesPhotosEnabled]) const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0 // Drag a marker onto a day (#891). Pointer-driven, so it is off wherever diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 513e1ddc1..43e0d9104 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1735,7 +1735,7 @@ describe('MapViewGL', () => { expect(srcs()).toContain('data:image/png;base64,BBB') }) - it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and the proxy url is used as the id', async () => { + it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and a picked proxy url is both id and cache key', async () => { vi.mocked(photoService.isLoading).mockImplementation((key: string) => key === 'osm-loading') const places = [ buildMapPlace({ id: 94, lat: 48.4, lng: 2.4, osm_id: 'osm-loading' }), @@ -1746,7 +1746,56 @@ describe('MapViewGL', () => { await act(async () => {}) expect(photoService.fetchPhoto).toHaveBeenCalledTimes(1) - expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum') + expect(photoService.fetchPhoto).toHaveBeenCalledWith('/api/maps/place-photo/abc', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum') + }) + + it('FE-COMP-MAPVIEWGL-101: an uploaded photo fills its marker whatever its proportions', async () => { + // A phone photo is no 48px square like the thumbs. Sized by attributes it lost to + // the stylesheet's `height: auto` and sat as a sliver in a disc of category colour. + loadOnAttach() + render() + await act(async () => {}) + + const img = glMarkers.created[0].element.querySelector('img')! + expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg') + expect(img.style.width).toBe('100%') + expect(img.style.height).toBe('100%') + }) + + it('FE-COMP-MAPVIEWGL-102: taking the upload off a place asks for its auto photo again, without a reload', async () => { + const place = buildMapPlace({ id: 15, lat: 48.15, lng: 2.15, google_place_id: 'gp-15', name: 'Tower', image_url: '/uploads/places/own.jpg' }) + const { rerender } = render() + await act(async () => {}) + expect(photoService.fetchPhoto).not.toHaveBeenCalled() + + rerender() + await act(async () => {}) + + expect(photoService.fetchPhoto).toHaveBeenCalledWith('gp-15', 'gp-15', 48.15, 2.15, 'Tower') + }) + + it('FE-COMP-MAPVIEWGL-103: picking another suggested photo replaces the thumb of the old one', async () => { + loadOnAttach() + const first = '/api/maps/place-photo/gp-16~p0/bytes' + const second = '/api/maps/place-photo/gp-16~p1/bytes' + // Everything seen before the new pick has a thumb: the place's auto photo under + // its provider id and the first pick under its own url. + vi.mocked(photoService.getCached).mockImplementation((key: string) => ( + key === 'gp-16' || key === first + ? ({ thumbDataUrl: 'data:image/png;base64,OLD' } as unknown as ReturnType) + : undefined + )) + const place = buildMapPlace({ id: 16, lat: 48.16, lng: 2.16, google_place_id: 'gp-16', name: 'Tower', image_url: first }) + const { rerender } = render() + await flushFrames() + + rerender() + await flushFrames() + + expect(photoService.fetchPhoto).toHaveBeenCalledWith(second, second, 48.16, 2.16, 'Tower') + // Until its thumb is ready the marker shows the new pick itself, not the old thumb. + const latest = glMarkers.created[glMarkers.created.length - 1] + expect(latest.element.querySelector('img')?.getAttribute('src')).toBe(second) }) it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => { diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index db8584d28..9f21dfb4f 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext' import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' -import { isCustomPlaceImage, photoCacheKey } from './placePhoto' +import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto' import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' @@ -20,7 +20,6 @@ import { ReservationMapboxOverlay } from './reservationsMapbox' import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' -import { escapeHtml } from '@trek/shared' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import LocationButton from './LocationButton' import { useGeolocation } from '../../hooks/useGeolocation' @@ -406,7 +405,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_ overflow:hidden;background:${bgColor}; box-sizing:content-box; "> - + ${markerPhotoHtml(photoUrl)}
${badgeHtml} ` @@ -1720,7 +1719,7 @@ export function MapViewGL({ // simultaneous thumb arrivals into one re-render. const pendingThumbsRef = useRef>({}) const thumbRafRef = useRef(null) - const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) + const photoSources = useMemo(() => photoSourcesKey(places), [places]) useEffect(() => { if (!places || places.length === 0 || !placesPhotosEnabled) return const cleanups: (() => void)[] = [] @@ -1771,7 +1770,7 @@ export function MapViewGL({ thumbRafRef.current = null } } - }, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps + }, [photoSources, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps // Reconcile markers with places + photos. The clustered GeoJSON source decides // which points are currently unclustered, and we render the existing rich HTML diff --git a/client/src/components/Map/placePhoto.test.ts b/client/src/components/Map/placePhoto.test.ts new file mode 100644 index 000000000..811eb26cc --- /dev/null +++ b/client/src/components/Map/placePhoto.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto' + +function imgOf(html: string): HTMLImageElement { + const holder = document.createElement('div') + holder.innerHTML = html + return holder.querySelector('img')! +} + +describe('isCustomPlaceImage', () => { + it('FE-MAP-PLACEPHOTO-001: an upload or an inline image is shown as it is, a provider photo is not', () => { + expect(isCustomPlaceImage('/uploads/places/a.jpg')).toBe(true) + expect(isCustomPlaceImage('data:image/png;base64,AAA')).toBe(true) + expect(isCustomPlaceImage('/api/maps/place-photo/gp-1~p0/bytes')).toBe(false) + expect(isCustomPlaceImage(null)).toBe(false) + }) +}) + +describe('markerPhotoHtml', () => { + it('FE-MAP-PLACEPHOTO-002: sizes the picture by its style, where no stylesheet rule can undo it', () => { + // Preflight's `height: auto` and Leaflet's `width: auto` both outrank a width or + // height attribute, so a photo that is not a small square has to be pinned here. + const img = imgOf(markerPhotoHtml('/uploads/places/wide.jpg')) + expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg') + expect(img.style.width).toBe('100%') + expect(img.style.height).toBe('100%') + expect(img.hasAttribute('width')).toBe(false) + expect(img.hasAttribute('height')).toBe(false) + }) + + it('FE-MAP-PLACEPHOTO-003: escapes the url, which a user can set', () => { + const html = markerPhotoHtml('/uploads/x" onerror="alert(1)" y="') + expect(html).not.toContain('onerror="alert(1)"') + expect(imgOf(html).getAttribute('src')).toBe('/uploads/x" onerror="alert(1)" y="') + }) +}) + +describe('photoCacheKey', () => { + it('FE-MAP-PLACEPHOTO-004: a picked provider photo is its own key, ahead of the provider id', () => { + expect(photoCacheKey({ image_url: '/api/maps/place-photo/gp-1~p2/bytes', google_place_id: 'gp-1', lat: 1, lng: 2 })) + .toBe('/api/maps/place-photo/gp-1~p2/bytes') + }) + + it('FE-MAP-PLACEPHOTO-005: otherwise the provider id, then the coordinates, then nothing', () => { + expect(photoCacheKey({ image_url: '/uploads/places/a.jpg', google_place_id: 'gp-1', osm_id: 'node/1' })).toBe('gp-1') + expect(photoCacheKey({ osm_id: 'node/1', lat: 1, lng: 2 })).toBe('node/1') + expect(photoCacheKey({ lat: 0, lng: 0 })).toBe('0,0') + expect(photoCacheKey({ image_url: 'https://example.com/a.jpg', lat: null, lng: null })).toBe('') + }) +}) + +describe('photoSourcesKey', () => { + const place = { id: 7, google_place_id: 'gp-7', lat: 48, lng: 2 } + + it('FE-MAP-PLACEPHOTO-006: changes when an upload comes off or another photo is picked', () => { + const withUpload = photoSourcesKey([{ ...place, image_url: '/uploads/places/a.jpg' }]) + const bare = photoSourcesKey([{ ...place, image_url: null }]) + const picked = photoSourcesKey([{ ...place, image_url: '/api/maps/place-photo/gp-7~p0/bytes' }]) + const pickedAgain = photoSourcesKey([{ ...place, image_url: '/api/maps/place-photo/gp-7~p1/bytes' }]) + expect(new Set([withUpload, bare, picked, pickedAgain]).size).toBe(4) + }) + + it('FE-MAP-PLACEPHOTO-007: stays put when one upload replaces another, which needs no thumb', () => { + expect(photoSourcesKey([{ ...place, image_url: '/uploads/places/a.jpg' }])) + .toBe(photoSourcesKey([{ ...place, image_url: '/uploads/places/b.jpg' }])) + }) +}) diff --git a/client/src/components/Map/placePhoto.ts b/client/src/components/Map/placePhoto.ts index 71874b82c..3086a9002 100644 --- a/client/src/components/Map/placePhoto.ts +++ b/client/src/components/Map/placePhoto.ts @@ -1,3 +1,16 @@ +import { escapeHtml } from '@trek/shared' + +const PHOTO_PROXY_PREFIX = '/api/maps/place-photo/' + +/** The fields of a place that decide which picture its marker gets. */ +interface PhotoSource { + image_url?: string | null + google_place_id?: string | null + osm_id?: string | null + lat?: number | null + lng?: number | null +} + /** * A place's image_url that is directly displayable on a map marker and should * win over the auto-fetched provider thumbnail — a custom uploaded image (#1136) @@ -11,18 +24,41 @@ export function isCustomPlaceImage(url: string | null | undefined): boolean { /** * Cache key under which a place's auto-fetched thumbnail is stored: the provider - * id when there is one, otherwise the coordinates. A place with neither has no - * identity to cache under, so it gets the empty string — callers skip it rather + * photo the user picked when there is one, else the provider id, otherwise the + * coordinates. The picked photo comes first because picking another picture of the + * same place leaves its provider id alone, and the thumb cached under that id + * would go on answering for the new one. A place with none of these has no + * identity to cache under, so it gets the empty string: callers skip it rather * than filing every coordinate-less place under one shared entry. */ -export function photoCacheKey(place: { - google_place_id?: string | null - osm_id?: string | null - lat?: number | null - lng?: number | null -}): string { +export function photoCacheKey(place: PhotoSource): string { + if (place.image_url?.startsWith(PHOTO_PROXY_PREFIX)) return place.image_url if (place.google_place_id) return place.google_place_id if (place.osm_id) return place.osm_id if (place.lat == null || place.lng == null) return '' return `${place.lat},${place.lng}` } + +/** + * What the maps' thumb loading depends on, as one string to key the effect on. + * Place ids alone missed every change of picture: taking an upload off a place or + * picking another suggested photo changes no id, so the marker kept a category + * dot or the old thumb until the page was reloaded. + */ +export function photoSourcesKey(places: Array): string { + return places.map(p => `${p.id}:${isCustomPlaceImage(p.image_url) ? 'own' : photoCacheKey(p)}`).join('|') +} + +/** + * The picture inside a round place marker, for both the Leaflet and the GL map. + * + * Sized by its style and not by width/height attributes, because stylesheet rules + * beat attributes: Tailwind's preflight gives every img `height: auto`, and + * Leaflet's marker pane adds `width: auto`. The 48px square thumbs got away with + * that. An uploaded photo kept its own proportions on the GL map and its full pixel + * size on Leaflet, where only the rounded-off corner of a 4000px image lands inside + * the circle, so the marker showed nothing but its category colour. + */ +export function markerPhotoHtml(url: string): string { + return `` +} diff --git a/client/src/components/Packing/useBagTotalsPing.ts b/client/src/components/Packing/useBagTotalsPing.ts index e63687749..c0c2fa8a9 100644 --- a/client/src/components/Packing/useBagTotalsPing.ts +++ b/client/src/components/Packing/useBagTotalsPing.ts @@ -1,94 +1,13 @@ -import { useEffect, useRef } from 'react' -import { addListener, removeListener } from '../../api/websocket' -import { isEffectivelyOnline, onNetworkModeChange } from '../../sync/networkMode' - -/** - * How long pings are collected before one refetch goes out. - * - * Long enough to fold a burst into a single request — clearing twelve checked - * items issues twelve deletes, and an import writes a whole list — short enough - * that a single edit still reads as instant. - */ -const COALESCE_MS = 250 +import { useServerPing } from '../../sync/useServerPing' /** * Re-read the bag weights when the server says they moved (#2191). * * The ping is content-free by design: totals are summed across every member, * including people whose items this client may not see, so the event cannot - * carry the new numbers without leaking what produced them. That makes every - * ping a refetch, which is why this coalesces bursts and never lets two - * requests overlap — the server pings the whole room, the originating socket - * included, so a busy trip would otherwise have every client refetching once - * per written item. - * - * A ping sent while this client is disconnected is gone for good, and bags have - * no offline cache to sync back, so the two moments where that can have happened - * count as pings of their own: the room re-join after a reconnect, and the - * return to online. - * - * `reload` may change identity every render; the latest one is always used. + * carry the new numbers without leaking what produced them. `useServerPing` + * carries the coalescing, the in-flight guard and the reconnect handling. */ export function useBagTotalsPing(enabled: boolean, reload: () => Promise): void { - const reloadRef = useRef(reload) - reloadRef.current = reload - - useEffect(() => { - if (!enabled) return - let timer: ReturnType | null = null - let inFlight = false - /** A ping that arrived while a refetch was running: serve it once that lands. */ - let pendingAfterFlight = false - let cancelled = false - - const run = (): void => { - if (cancelled) return - if (inFlight) { - pendingAfterFlight = true - return - } - inFlight = true - void reloadRef.current().finally(() => { - inFlight = false - if (pendingAfterFlight && !cancelled) { - pendingAfterFlight = false - run() - } - }) - } - - const schedule = (): void => { - if (timer) return // already collecting this burst - timer = setTimeout(() => { - timer = null - run() - }, COALESCE_MS) - } - - const handler = (event: Record) => { - // 'joined' is the room re-join the socket sends on every reconnect, so it - // is also the moment the totals on screen are the ones from before the - // drop: whatever moved them in the meantime pinged into a closed socket. - if (event.type !== 'packing:bag-totals' && event.type !== 'joined') return - schedule() - } - - let wasOnline = isEffectivelyOnline() - const onMode = (): void => { - const online = isEffectivelyOnline() - // Only the way back matters: that is when the surfaces stop summing what - // this client can see and go back to trusting the server numbers. - if (online && !wasOnline) schedule() - wasOnline = online - } - - addListener(handler) - const unsubscribeMode = onNetworkModeChange(onMode) - return () => { - cancelled = true - removeListener(handler) - unsubscribeMode() - if (timer) clearTimeout(timer) - } - }, [enabled]) + useServerPing('packing:bag-totals', enabled, reload) } diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 32ac8a7aa..ed99d04b1 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -585,6 +585,20 @@ describe('PlaceFormModal', () => { expect(screen.getByDisplayValue('20:34')).toBeInTheDocument(); }); + it('FE-PLANNER-PLACEFORM-086: on a road trip the End says the drive leaves at it', () => { + const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' }); + const assignment = buildAssignment({ id: 10, day_id: 5, place }); + render(); + expect(screen.getByText('On the road trip, the drive leaves at this time.')).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-087: in Days the End stays a plain label', () => { + const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' }); + const assignment = buildAssignment({ id: 10, day_id: 5, place }); + render(); + expect(screen.queryByText('On the road trip, the drive leaves at this time.')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => { // Build an assignment whose place has end_time before place_time const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' }); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index eb0979136..a8bf6428c 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -68,6 +68,8 @@ interface PlaceFormModalProps { * of the drive it belongs on. Absent, nothing about the form changes. */ serviceStop?: ServiceStopMode | null + /** Road trip mode is on, where a visit's End is when the drive leaves it. */ + roadtripActive?: boolean } @@ -1181,6 +1183,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { assignmentId={assignmentId} dayAssignments={dayAssignments} hasTimeError={hasTimeError} + endIsLeave={!!props.roadtripActive} t={t} /> )} @@ -1270,10 +1273,13 @@ interface TimeSectionProps { assignmentId: number | null dayAssignments: Assignment[] hasTimeError: boolean + /** On a road trip the End is when the drive leaves, which the field says. In Days it + * stays the plain label it has always been: nothing is scheduled off it there. */ + endIsLeave: boolean t: (key: string, params?: Record) => string } -function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) { +function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) { const collisions = useMemo(() => { if (!assignmentId || !form.place_time || form.place_time.length < 5) return [] @@ -1311,6 +1317,7 @@ function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTime value={form.end_time} onChange={v => handleChange('end_time', v)} /> + {endIsLeave &&

{t('roadtrip.stop.endIsLeave')}

}
{hasTimeError && ( diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index dbb63d409..13a6c42c4 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -322,7 +322,10 @@ describe('RoadtripSidebar', () => { const add = within(chain).getByText('+') fireEvent.click(add) - expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null }) + // The visit rides along with the place, so the dialog can reach its end time. + expect(onEditStay).toHaveBeenCalledWith({ + placeId: 20, name: 'Berlin', minutes: null, arrival: null, departure: null, leaveAt: null, missedBy: null, assignmentId: 2, dayId: 1, + }) }) it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => { @@ -342,6 +345,53 @@ describe('RoadtripSidebar', () => { expect(screen.getByLabelText(/45/)).toBeInTheDocument() }) + it('FE-ROADTRIP-SIDEBAR-049: a stop left at a set time shows the stay that makes, and until when', () => { + // The stay the place carries is 30 min; the End at two is what the drive keeps to. + const onEditStay = vi.fn() + const stops = [ + stop({ assignmentId: 1, name: 'Hamburg' }), + stop({ assignmentId: 2, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }), + ] + const schedule = { + entries: [ + { arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 }, + { arrival: '10:00', departure: '14:00', anchored: false, dayOffset: 0 }, + ], + warnings: [], + } + wrap() + + expect(screen.getByText('4 h')).toBeInTheDocument() + expect(screen.getByText('until 14:00')).toBeInTheDocument() + expect(screen.queryByText('30 min')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 4 h until 14:00' })) + expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', departure: '14:00' })) + }) + + it('FE-ROADTRIP-SIDEBAR-050: a stop reached after the time it is left at says so, beside a late arrival', () => { + const stops = [ + stop({ assignmentId: 1, name: 'Hamburg' }), + stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', leaveAt: '14:00', time: '14:00' }), + ] + const schedule = { + entries: stops.map(() => ({ arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 })), + warnings: [ + { index: 1, code: 'late' as const, minutes: 30 }, + { index: 1, code: 'missedLeave' as const, minutes: 30 }, + ], + } + const onEditStay = vi.fn() + wrap() + + expect(screen.getByLabelText('Arrives 30 min after the time you set')).toBeInTheDocument() + expect(screen.getByLabelText('Arrives 30 min after the time you set to leave')).toBeInTheDocument() + // Left the moment it is reached: a zero, until the time it was meant to go. + expect(screen.getByText('0 min')).toBeInTheDocument() + // The dialog is handed the schedule's own finding, so it can say the same. + fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 0 min until 14:00' })) + expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', missedBy: 30 })) + }) + it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => { const stops = [ stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }), diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 4c53731f5..93fa59323 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -31,6 +31,8 @@ import { FS } from './typeScale' import type { RouteSegment } from '../../types' import EmptyState from '../shared/EmptyState' import AutomaticDayStop from './AutomaticDayStop' +import type { StayDraft } from './RoadtripStayModal' +import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading' interface RoadtripSidebarProps { onFocusPoint?: (lat: number, lng: number) => void @@ -62,7 +64,7 @@ interface RoadtripSidebarProps { * Opens the dialog for how long a stop takes. Absent leaves every stay read-only — * which is also what a viewer sees. */ - onEditStay?: (stop: { placeId: number; name: string; minutes: number | null; arrival: string | null }) => void + onEditStay?: (stop: StayDraft) => void /** * Turns a stop into a pause on the drive, or back into a destination. * @@ -280,10 +282,17 @@ function FillBadge({ percent, own, onEdit }: { ) } -function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null { +/** + * How long the stop takes. A stop the traveller leaves at a set time is stood at until + * then, so it reads the stay the schedule made of that and says until when. + */ +function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null { const { t } = useTranslation() - const text = minutes ? formatDurationShort(minutes * 60) : null - if (!text && !onEdit) return null + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const shown = shownStay(stay) + const text = shown === null ? null : formatDurationShort(shown * 60) + const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null + if (!text && !until && !onEdit) return null const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge' const label = ( @@ -301,7 +310,8 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => }`} style={{ fontSize: FS.label }} > - {text ?? '+'} + {text ?? (until ? null : '+')} + {until ? {until} : null} ) @@ -323,7 +333,7 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => e.stopPropagation() onEdit() }} - aria-label={text ? `${t('roadtrip.stop.stay')}: ${text}` : t('roadtrip.stay.add')} + aria-label={text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')} className={`${shell} cursor-pointer transition-colors hover:border-content-faint`} > {label}{value} @@ -749,9 +759,10 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: { stop: RoadtripStop entry: ScheduleEntry | undefined - /** How late the drive reaches a time pinned on this pause, the same finding a numbered - * stop shows. A fuel or charging halt can carry a pinned time like anything else. */ - late: ScheduleWarning | undefined + /** How late the drive reaches a time pinned on this pause, or the time it was set to + * be left at: the same findings a numbered stop shows. A fuel or charging halt can + * carry a pinned time like anything else. */ + late: ScheduleWarning[] /** Findings about the drive that ARRIVES here. A charging halt is a stop like any other * as far as the tank is concerned, so it carries them the same way a numbered one does. */ driveFindings?: ScheduleWarning[] @@ -834,7 +845,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE {stop.name}{stop.stopType === 'charging' && } - + {refuelsRange(stop.stopType, vehicleKind) ? ( : null} {(driveFindings ?? []).map(w => )} - + {late.map(w => )} {entry?.arrival ? : null} @@ -985,7 +996,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s /** Position within the day — the same count the map badges its markers with. */ number: number entry: ScheduleEntry | undefined - late: ScheduleWarning | undefined + /** Arriving after a pinned time, or after the time this stop was set to be left at. */ + late: ScheduleWarning[] /** Findings about the drive LEAVING this stop, when the limits are set and it goes over. */ driveFindings?: ScheduleWarning[] selected: boolean @@ -1089,7 +1101,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s enough to change the plan. The dashed line on the map already says there is one; the number is for luggage, a gate, a track a hire car should not be on. */} - + {refuelsRange(stop.stopType, vehicleKind) ? ( : null} {(driveFindings ?? []).map(w => )} - + {late.map(w => )} {entry?.arrival ? : null} @@ -1118,10 +1130,9 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s * its own: they are all answers to "what does this stop cost", and a warning on its own row * pushed every following stop down for a finding that fits in a pill. */ -function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.ReactElement | null { +function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement { const { t } = useTranslation() - if (!late) return null - const label = t('roadtrip.warn.late', { minutes: late.minutes ?? 0 }) + const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 }) return ( w.index === i) + // overnight crossing swallow the "you arrive late" flag without a trace, and a stop + // can be late for the time it is reached and the time it is left at both at once. + const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave')) const ownDay = stop.ownerDayId const ownIndex = stop.ownerIndex return ( @@ -1388,11 +1400,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on w.code === 'late')} + late={lateness} driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} - onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined} onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} /> @@ -1401,7 +1413,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on stop={stop} number={counted} entry={day.schedule.entries[i]} - late={marks.find(w => w.code === 'late')} + late={lateness} driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} continues={i < last} @@ -1409,7 +1421,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined} canMove={{ up: i > 0, down: i < last }} - onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined} onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} /> diff --git a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx index 4619321bf..8b456192b 100644 --- a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx +++ b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx @@ -2,10 +2,14 @@ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' import { useSettingsStore } from '../../store/settingsStore' +import { useAuthStore } from '../../store/authStore' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useTripStore } from '../../store/tripStore' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal' /** - * FE-STAYMODAL-001..011 — how long the traveller stays at one stop. + * FE-STAYMODAL-001..022: how long the traveller stays at one stop. * * The value is `places.duration_minutes`, which the schedule has read for years * and nothing could write. Two things are worth pinning: that clearing sends a @@ -158,3 +162,93 @@ describe('RoadtripStayModal', () => { expect(screen.queryByText('+1')).not.toBeInTheDocument() }) }) + +describe('RoadtripStayModal for a stop left at a set time', () => { + // The visit's End is when the drive leaves it, so there is no length to choose: the + // stay is what the time leaves of it. What the dialog offers instead is taking the end + // time off, after which the stay is a choice again. + const leaving = { minutes: 30, arrival: '10:00', departure: '14:00', leaveAt: '14:00', assignmentId: 102, dayId: 11 } + let setAssignmentTimes: ReturnType + + beforeEach(() => { + resetAllStores() + useSettingsStore.setState({ settings: { time_format: '24h' } as never }) + setAssignmentTimes = vi.fn(async () => undefined) + seedStore(useAuthStore, { user: { id: 1, role: 'user' } }) + seedStore(useTripStore, { + trip: { id: 4, user_id: 1 }, + assignments: { '11': [{ id: 102, day_id: 11, place_id: 9, order_index: 0, assignment_time: null, assignment_end_time: '14:00' }] }, + }) + useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never) + }) + + it('FE-STAYMODAL-015: shows the stay the time makes and when the drive leaves, with nothing to slide', () => { + open(leaving) + expect(screen.getByText('4 h')).toBeInTheDocument() + expect(screen.getByText('This visit has an end time, so the drive leaves at 14:00.')).toBeInTheDocument() + expect(screen.getByText('10:00')).toBeInTheDocument() + expect(screen.getByText('14:00')).toBeInTheDocument() + expect(screen.queryByRole('slider')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^save$/i })).not.toBeInTheDocument() + }) + + it('FE-STAYMODAL-016: taking the end time off writes it through the visit and brings the slider back', async () => { + const { onSave } = open(leaving) + fireEvent.click(screen.getByRole('button', { name: 'Remove end time' })) + await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null })) + // The stay the place carries is where the slider starts again. + await waitFor(() => expect(slider().value).toBe('30')) + expect(onSave).not.toHaveBeenCalled() + }) + + it('FE-STAYMODAL-017: a traveller who may not change the day can read it but not remove it', () => { + usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } }) + seedStore(useTripStore, { trip: { id: 4, user_id: 2 } }) + open(leaving) + expect(screen.getByText('4 h')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument() + }) + + it('FE-STAYMODAL-018: without an arrival it can only say until when', () => { + open({ ...leaving, arrival: null, departure: '14:00' }) + expect(screen.getByText('until 14:00')).toBeInTheDocument() + expect(screen.queryByText('10:00')).not.toBeInTheDocument() + }) + + it('FE-STAYMODAL-019: closing changes nothing', () => { + const { onClose, onSave } = open(leaving) + fireEvent.click(screen.getByRole('button', { name: /^close$/i })) + expect(onClose).toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + expect(setAssignmentTimes).not.toHaveBeenCalled() + }) + + it('FE-STAYMODAL-020: reached after the time, it says so and shows the departure the drive actually makes', () => { + open({ ...leaving, arrival: '14:30', departure: '14:30', missedBy: 30 }) + expect(screen.getByText('0 min')).toBeInTheDocument() + expect(screen.getAllByText('14:30')).toHaveLength(2) + // One departure, not two: the promise of 14:00 is gone, the finding takes its place. + expect(screen.getByText('Arrives 30 min after the time you set to leave')).toBeInTheDocument() + expect(screen.queryByText('This visit has an end time, so the drive leaves at 14:00.')).not.toBeInTheDocument() + }) + + it('FE-STAYMODAL-021: travel hours over before the time say so, with no length and no departure', () => { + // The window shut at 18:00, the End is 20:00: the drive goes on the next morning. + open({ ...leaving, arrival: '10:30', departure: '18:00', leaveAt: '20:00' }) + expect(screen.getByText('until 20:00')).toBeInTheDocument() + expect(screen.getByText('The travel day ends before 20:00, so the drive goes on the next morning.')).toBeInTheDocument() + expect(screen.queryByText('18:00')).not.toBeInTheDocument() + expect(screen.queryByText('7 h 30 min')).not.toBeInTheDocument() + }) + + it('FE-STAYMODAL-022: an End the place carries too goes with the removal, or the slider would not be the truth', async () => { + const updatePlace = vi.fn(async () => undefined) + seedStore(useTripStore, { places: [{ id: 9, name: 'Rasthof Dammer Berge', end_time: '14:00' }] }) + useTripStore.setState({ updatePlace } as never) + open(leaving) + fireEvent.click(screen.getByRole('button', { name: 'Remove end time' })) + await waitFor(() => expect(updatePlace).toHaveBeenCalledWith(4, 9, { end_time: null })) + expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null }) + await waitFor(() => expect(slider().value).toBe('30')) + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripStayModal.tsx b/client/src/components/Roadtrip/RoadtripStayModal.tsx index 1a0bd8e52..88457ff8b 100644 --- a/client/src/components/Roadtrip/RoadtripStayModal.tsx +++ b/client/src/components/Roadtrip/RoadtripStayModal.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react' +import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react' import Modal from '../shared/Modal' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { formatDurationShort, formatClock, parseClock } from './roadtripModel' import { formatClockTime } from '../../utils/formatters' +import { useLeaveMode, type LeaveMode } from './useLeaveMode' /** * The lengths a stop usually takes, so the common answer is one tap. @@ -33,6 +34,15 @@ export interface StayDraft { minutes: number | null /** When the drive gets here, so the dialog can show what the stay pushes back. */ arrival?: string | null + /** When the schedule has it leave again. */ + departure?: string | null + /** The time the visit is set to be left at, which makes the stay rather than taking one. */ + leaveAt?: string | null + /** How many minutes after that time the drive gets here, when the schedule found it cannot make it. */ + missedBy?: number | null + assignmentId?: number + /** The day the visit is stored on. */ + dayId?: number } interface RoadtripStayModalProps { @@ -75,6 +85,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const [minutes, setMinutes] = useState(0) const [saving, setSaving] = useState(false) + const leave = useLeaveMode(stop) // Reopened on a different stop, so it starts from that stop's own value rather than // from whatever the last one was left on. @@ -97,6 +108,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta }, [stop?.arrival, minutes, is12h]) if (!stop) return null + if (leave.until) return const commit = async (value: number) => { if (saving) return @@ -163,31 +175,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta {/* What the stay costs the rest of the day. The arrival cannot move — the drive decides it — so the arrow shows the one end this dialog does move. */} - {times ? ( -
- - - {t('roadtrip.stay.arrive')} - - {times.arrive} - - - - - {t('roadtrip.stay.leave')} - - - {times.leave} - {times.leaveCarry > 0 ? ( - - {`+${times.leaveCarry}`} - {` ${t('roadtrip.warn.overnight')}`} - - ) : null} - - -
- ) : null} + {times ? : null}
{PRESETS.map(value => ( @@ -237,3 +225,106 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta ) } + +/** + * The dialog for a stop the traveller leaves at a set time. + * + * Nothing to choose: the stay is whatever is left between the arrival and that time, so + * it is shown rather than offered. The one thing to do here is take the end time off the + * visit, and the ordinary dialog takes over the moment that lands. + * + * The sentence under the number says what the drive actually does. Reached too late it + * leaves on arrival, which is the case this dialog gets opened to fix; with the travel + * hours over first it goes on the next morning. Saying "leaves at 14:00" over a box that + * reads 14:30 gave two departures for one stop. + */ +function LeaveTime({ stop, leave, until: leaveAt, is12h, onClose }: { + stop: StayDraft + leave: LeaveMode + until: string + is12h: boolean + onClose: () => void +}): React.ReactElement { + const { t } = useTranslation() + const until = formatClockTime(leaveAt, is12h) + const arrive = stop.arrival ? formatClockTime(stop.arrival, is12h) : null + const departure = stop.departure ? formatClockTime(stop.departure, is12h) : until + return ( + + + {t('roadtrip.stop.stay')} + + }> +
+

{stop.name}

+ + {leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)} + + {leave.missedBy !== null ? ( +

+ + {t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })} +

+ ) : ( +

+ {t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })} +

+ )} + {/* The day ending first means the row's departure is only where it stops for the + night, not a time it leaves. */} + {arrive && !leave.dayEndsFirst ? : null} +
+ +
+ {leave.remove ? ( + + ) : null} + +
+
+ ) +} + +/** Arrival, arrow, departure: the two ends of the stay, with the day the second lands on. */ +function ArriveLeave({ arrive, leave, carry = 0 }: { arrive: string; leave: string; carry?: number }): React.ReactElement { + const { t } = useTranslation() + return ( +
+ + + {t('roadtrip.stay.arrive')} + + {arrive} + + + + + {t('roadtrip.stay.leave')} + + + {leave} + {carry > 0 ? ( + + {`+${carry}`} + {` ${t('roadtrip.warn.overnight')}`} + + ) : null} + + +
+ ) +} diff --git a/client/src/components/Roadtrip/VisitControls.test.tsx b/client/src/components/Roadtrip/VisitControls.test.tsx index d5d6ce6eb..205f846ef 100644 --- a/client/src/components/Roadtrip/VisitControls.test.tsx +++ b/client/src/components/Roadtrip/VisitControls.test.tsx @@ -17,3 +17,14 @@ it('keeps stay editing when daily travel times are off', () => { expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled() expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument() }) + +it('reads the stay a set leave time makes, and says until when', () => { + render() + expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 4 h roadtrip.stay.until' })).toBeEnabled() +}) + +it('says only until when while the drive has not timed the arrival', () => { + render() + expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled() + expect(screen.queryByText('+')).not.toBeInTheDocument() +}) diff --git a/client/src/components/Roadtrip/VisitControls.tsx b/client/src/components/Roadtrip/VisitControls.tsx index 2f16cc924..cc98c623b 100644 --- a/client/src/components/Roadtrip/VisitControls.tsx +++ b/client/src/components/Roadtrip/VisitControls.tsx @@ -1,23 +1,31 @@ import { Clock, Pencil } from 'lucide-react' import EndDayControl, { type EndDayControlProps } from './EndDayControl' import { useTranslation } from '../../i18n/TranslationContext' +import { useSettingsStore } from '../../store/settingsStore' +import { formatClockTime } from '../../utils/formatters' import { formatDurationShort } from './roadtripModel' export interface RoadtripStayControl { minutes: number | null + /** The time the visit is set to be left at, which is what makes its stay. */ + until?: string | null onEdit?: () => void } export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) { const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const until = stay?.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null return (
{endDay && } {stay && }
diff --git a/client/src/components/Roadtrip/roadtripRowModel.test.ts b/client/src/components/Roadtrip/roadtripRowModel.test.ts index c254ccfcd..9295537bf 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.test.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.test.ts @@ -15,7 +15,7 @@ import { import type { ScheduleEntry, ScheduleWarning } from './roadtripModel' import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' -// FE-RTROW-001 to FE-RTROW-039 +// FE-RTROW-001 to FE-RTROW-044 function stop(name: string, over: Partial = {}): RoadtripStop { return { @@ -277,6 +277,23 @@ describe('pickWarning', () => { it('FE-RTROW-014: nothing to report is null, not a placeholder', () => { expect(pickWarning([])).toBeNull() }) + + it('FE-RTROW-043: missing the time a stop is left at ranks right after being late for it', () => { + const missed: ScheduleWarning = { index: 0, code: 'missedLeave', minutes: 30 } + expect(pickWarning([{ index: 0, code: 'range', sinceKm: 700 }, missed])).toBe(missed) + expect(pickWarning([missed, { index: 0, code: 'late', minutes: 5 }])?.code).toBe('late') + }) +}) + +describe('a stop left at a set time', () => { + it('FE-RTROW-044: its row carries the stay the time makes, not the one the place has', () => { + const d = day([stop('A'), stop('Bremen', { dwellMinutes: 30, leaveAt: '14:00' })], { + schedule: { entries: [entry('09:00'), entry('10:00', { departure: '14:00' })], warnings: [] }, + }) + const [, bremen] = stopRows(roadtripRows(d)) + expect(bremen.dwellMinutes).toBe(240) + expect(bremen.departure).toBe('14:00') + }) }) describe('stageOf', () => { diff --git a/client/src/components/Roadtrip/roadtripRowModel.ts b/client/src/components/Roadtrip/roadtripRowModel.ts index 4a51c8cc3..8659e006b 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.ts @@ -1,5 +1,6 @@ import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' +import { readStay } from './stayReading' /** * A road trip day, flattened into the rows a screen draws, with no React in sight. @@ -32,12 +33,13 @@ export interface StopRow { entry: ScheduleEntry | undefined /** The clock this row shows on the right: when the traveller gets there. */ time: string | null - /** When they leave again, which is arrival plus the stay. Null without a schedule. */ + /** When they leave again: arrival plus the stay, or the time set to leave. Null without a schedule. */ departure: string | null /** True when a person pinned that time, which is what the pin in front of it means. */ pinned: boolean /** At most two marks fit a 375px row. This is the one warning that earned the second. */ warning: ScheduleWarning | null + /** How long the stop is stood at, which for one left at a set time is what that time leaves. */ dwellMinutes: number | null offRoadMeters: number | null } @@ -47,14 +49,15 @@ export interface StopRow { * * The desktop stacks all of them, which is honest on a 420px rail and unreadable on a * phone. The order is by what changes the next decision soonest: being late moves every - * arrival after it, running dry strands the car, and an over-long leg is a planning - * remark you can act on tonight. + * arrival after it, and so does missing the time a stop was meant to be left at; running + * dry strands the car; and an over-long leg is a planning remark you can act on tonight. */ const WARNING_RANK: Record = { late: 0, - range: 1, - leg: 2, - overnight: 3, + missedLeave: 1, + range: 2, + leg: 3, + overnight: 4, } export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null { @@ -92,7 +95,7 @@ function stopRow( departure: entry?.departure ?? null, pinned: !!entry?.anchored, warning: pickWarning(mine), - dwellMinutes: stop.dwellMinutes, + dwellMinutes: readStay(stop, entry).minutes, offRoadMeters: stop.offRoadMeters ?? null, } } diff --git a/client/src/components/Roadtrip/stayReading.test.ts b/client/src/components/Roadtrip/stayReading.test.ts new file mode 100644 index 000000000..47795cb8c --- /dev/null +++ b/client/src/components/Roadtrip/stayReading.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip' +import { inspectorStay, locateStop, missedLeaveOf, readStay, shownStay, stayDraftOf } from './stayReading' + +// FE-ROADTRIP-STAY-001..014: what a stay badge reads for a stop, and where the dialog +// finds the visit it opens on. + +const stop = (over: Partial = {}): RoadtripStop => ({ + assignmentId: 7, + ownerDayId: 1, + ownerIndex: 1, + placeId: 70, + name: 'Lueneburg', + lat: 53.2, + lng: 10.4, + time: null, + dwellMinutes: 30, + legMode: null, + incomingLegMode: null, + stopType: null, + ...over, +}) + +const entry = (arrival: string | null, departure: string | null) => ({ arrival, departure, anchored: false, dayOffset: 0 }) + +const day = (dayId: number, stops: RoadtripStop[], entries = stops.map(() => entry('10:00', '14:00'))): RoadtripDay => + ({ dayId, dayNumber: dayId, date: null, title: null, stops, schedule: { entries, warnings: [] } }) as unknown as RoadtripDay + +describe('readStay', () => { + it('FE-ROADTRIP-STAY-001: an ordinary stop reads the stay the place carries', () => { + expect(readStay(stop(), entry('10:00', '10:30'))).toEqual({ minutes: 30, until: null }) + }) + + it('FE-ROADTRIP-STAY-002: a stop left at a set time reads the stay the schedule made of it', () => { + expect(readStay(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({ minutes: 240, until: '14:00' }) + }) + + it('FE-ROADTRIP-STAY-003: a stay across midnight is not read as a negative one', () => { + expect(readStay(stop({ leaveAt: '01:00' }), entry('23:30', '01:00')).minutes).toBe(90) + }) + + it('FE-ROADTRIP-STAY-004: without an arrival it can only say until when', () => { + expect(readStay(stop({ leaveAt: '14:00:00' }), entry(null, '14:00'))).toEqual({ minutes: null, until: '14:00' }) + }) + + it('FE-ROADTRIP-STAY-005: a leave time that is not a clock is no leave time', () => { + expect(readStay(stop({ leaveAt: 'later' }), undefined)).toEqual({ minutes: 30, until: null }) + }) + + it('FE-ROADTRIP-STAY-012: reached after the time, it is left on arrival and stays nothing', () => { + expect(readStay(stop({ leaveAt: '14:00' }), entry('14:30', '14:30'))).toEqual({ minutes: 0, until: '14:00' }) + }) + + it('FE-ROADTRIP-STAY-013: travel hours that end first give no length, only until when', () => { + // Reached at half past ten, the window shuts at six, the End is at eight: the row's + // 18:00 is where the day stops, not a departure, so seven and a half hours would be + // a stay that ends neither at the End nor when the drive goes on the next morning. + expect(readStay(stop({ leaveAt: '20:00' }), entry('10:30', '18:00'))).toEqual({ + minutes: null, + until: '20:00', + dayEndsFirst: true, + }) + }) +}) + +describe('shownStay', () => { + it('FE-ROADTRIP-STAY-006: no stay on an ordinary stop shows nothing, a zero until a time shows zero', () => { + expect(shownStay({ minutes: 0, until: null })).toBeNull() + expect(shownStay({ minutes: null, until: null })).toBeNull() + expect(shownStay({ minutes: 45, until: null })).toBe(45) + expect(shownStay({ minutes: 0, until: '14:00' })).toBe(0) + }) +}) + +describe('locateStop', () => { + it('FE-ROADTRIP-STAY-007: finds the visit on the card that draws it, starting at the one it names', () => { + const drawn = stop({ ownerDayId: 1 }) + const days = [day(1, [stop({ assignmentId: 1 })]), day(2, [drawn])] + const located = locateStop(days, 7, 1) + expect(located).toMatchObject({ index: 0, stop: drawn }) + expect(located?.day.dayId).toBe(2) + expect(locateStop(days, 99)).toBeNull() + }) + + it('FE-ROADTRIP-STAY-008: an automatic night standing on the stop is not the stop', () => { + const night = stop({ automaticNight: { phase: 'end', fromDayNumber: 1 } }) + expect(locateStop([day(1, [night])], 7)).toBeNull() + }) +}) + +describe('the dialog draft', () => { + it('FE-ROADTRIP-STAY-009: carries the visit, its end time and both of its clocks', () => { + expect(stayDraftOf(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({ + placeId: 70, + name: 'Lueneburg', + minutes: 30, + arrival: '10:00', + departure: '14:00', + leaveAt: '14:00', + missedBy: null, + assignmentId: 7, + dayId: 1, + }) + }) + + it('FE-ROADTRIP-STAY-014: carries the finding of a missed time from the card, for the inspector too', () => { + const visit = stop({ leaveAt: '14:00' }) + const late = { + ...day(1, [stop({ assignmentId: 1 }), visit], [entry('13:30', '13:30'), entry('14:30', '14:30')]), + schedule: { + entries: [entry('13:30', '13:30'), entry('14:30', '14:30')], + warnings: [ + { index: 1, code: 'late' as const, minutes: 5 }, + { index: 1, code: 'missedLeave' as const, minutes: 30 }, + ], + }, + } as unknown as RoadtripDay + expect(missedLeaveOf(late, 1)).toBe(30) + expect(missedLeaveOf(late, 0)).toBeNull() + expect(stayDraftOf(visit, entry('14:30', '14:30'), 30).missedBy).toBe(30) + const onEdit = vi.fn() + inspectorStay([late], visit, { id: 70, name: 'Lueneburg' }, onEdit).onEdit?.() + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ missedBy: 30, departure: '14:30' })) + }) + + it('FE-ROADTRIP-STAY-010: the inspector tile reads the drive the rail reads', () => { + const onEdit = vi.fn() + const visit = stop({ leaveAt: '14:00' }) + const tile = inspectorStay([day(1, [visit])], visit, { id: 70, name: 'Lueneburg', duration_minutes: 30 }, onEdit) + expect(tile).toMatchObject({ minutes: 240, until: '14:00' }) + tile.onEdit?.() + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', assignmentId: 7 })) + }) + + it('FE-ROADTRIP-STAY-011: a place not on the drive falls back to the stay it carries', () => { + const onEdit = vi.fn() + const tile = inspectorStay([], undefined, { id: 70, name: 'Lueneburg', duration_minutes: 45 }, onEdit) + expect(tile).toMatchObject({ minutes: 45, until: null }) + tile.onEdit?.() + expect(onEdit).toHaveBeenCalledWith({ placeId: 70, name: 'Lueneburg', minutes: 45, arrival: null }) + expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).onEdit).toBeUndefined() + expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).minutes).toBe(20) + }) +}) diff --git a/client/src/components/Roadtrip/stayReading.ts b/client/src/components/Roadtrip/stayReading.ts new file mode 100644 index 000000000..76faf9cd3 --- /dev/null +++ b/client/src/components/Roadtrip/stayReading.ts @@ -0,0 +1,131 @@ +import { formatClock, parseClock, type ScheduleEntry } from './roadtripModel' +import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip' +import type { StayDraft } from './RoadtripStayModal' +import type { RoadtripStayControl } from './VisitControls' + +/** + * How long a stop is stood at, the way a stay badge shows it. + * + * For most stops that is the stay the place carries. A stop with a time set to leave it + * is stood at from whenever the drive gets there until that time, so its stay is what + * the schedule made of it and `until` says when it ends. The number is read here and + * never written back to the place: the stay belongs to the place, the end time to one + * visit of it, and a stored copy would be wrong the moment the arrival moved. + */ +export interface StayReading { + minutes: number | null + /** The time the stop is left at as it was set, or null for a stop that takes its stay. */ + until: string | null + /** + * The daily travel hours close before `until` comes. The stop is where that day ends and + * the drive goes on the next morning, so it is left neither at that time nor at the one + * the row shows, and no length measured against either would be true. + */ + dayEndsFirst?: boolean +} + +export function readStay( + stop: Pick, + entry: { arrival?: string | null; departure?: string | null } | null | undefined, +): StayReading { + const leave = parseClock(stop.leaveAt) + if (leave === null) return { minutes: stop.dwellMinutes, until: null } + const until = formatClock(leave) + const arrival = parseClock(entry?.arrival) + const departure = parseClock(entry?.departure) + if (arrival === null || departure === null) return { minutes: null, until } + // A schedule leaves such a stop at the time, or on arrival when it got there too late + // for it. Any other departure is the window shutting first. + if (departure !== leave && departure !== arrival) return { minutes: null, until, dayEndsFirst: true } + return { + // Round the clock once: reached before midnight and left the morning after, the + // departure reads as the smaller of the two. + minutes: (departure - arrival + 1440) % 1440, + until, + } +} + +/** How late the drive gets to a stop for the time it is set to be left at, or null. */ +export function missedLeaveOf(day: Pick, index: number): number | null { + return day.schedule.warnings.find(w => w.index === index && w.code === 'missedLeave')?.minutes ?? null +} + +/** + * The length a stay badge prints, or null for none. + * + * No stay on an ordinary stop is nothing to show. On a stop left at a set time a zero is + * an answer: it is left the moment the drive gets there. + */ +export function shownStay(stay: StayReading): number | null { + return stay.until ? stay.minutes : stay.minutes || null +} + +/** One visit on the routed days, with the entry of the card that draws it. */ +export interface LocatedStop { + day: RoadtripDay + index: number + stop: RoadtripStop + entry: ScheduleEntry | undefined +} + +/** + * Finds a visit across the routed days. + * + * A stop reached after a night drive is drawn on the card of the day it is reached on, + * not the one it is stored on, so `preferDayId` is only where the search starts. + */ +export function locateStop( + days: readonly RoadtripDay[], + assignmentId: number, + preferDayId?: number | null, +): LocatedStop | null { + const preferred = days.find(d => d.dayId === preferDayId) + for (const day of preferred ? [preferred, ...days.filter(d => d !== preferred)] : days) { + const index = day.stops.findIndex(s => s.assignmentId === assignmentId && !s.automaticNight) + if (index >= 0) return { day, index, stop: day.stops[index], entry: day.schedule.entries[index] } + } + return null +} + +/** + * What the stay dialog opens on for one stop of a card. + * + * `missedBy` is the card's own finding, carried rather than worked out again from the two + * clocks: which day a leave time falls on is the schedule's call, and a second reading of + * it here is a second answer. + */ +export function stayDraftOf(stop: RoadtripStop, entry: ScheduleEntry | undefined, missedBy: number | null = null): StayDraft { + return { + placeId: stop.placeId, + name: stop.name, + minutes: stop.dwellMinutes, + arrival: entry?.arrival ?? null, + departure: entry?.departure ?? null, + leaveAt: stop.leaveAt ?? null, + missedBy, + assignmentId: stop.assignmentId, + dayId: stop.ownerDayId, + } +} + +/** + * The place inspector's Stay tile. + * + * With the visit on the drive it reads the same as the rail does. A place selected + * without one it can be pinned to (planned twice, say) falls back to the stay it carries. + */ +export function inspectorStay( + days: readonly RoadtripDay[], + stop: RoadtripStop | undefined, + place: { id: number; name: string; duration_minutes?: number | null }, + onEdit?: (draft: StayDraft) => void, +): RoadtripStayControl { + const located = stop ? locateStop(days, stop.assignmentId, stop.ownerDayId) : null + const draft: StayDraft = located + ? stayDraftOf(located.stop, located.entry, missedLeaveOf(located.day, located.index)) + : { placeId: place.id, name: place.name, minutes: place.duration_minutes ?? null, arrival: null } + const reading = located + ? readStay(located.stop, located.entry) + : { minutes: stop ? stop.dwellMinutes : place.duration_minutes ?? null, until: null } + return { ...reading, onEdit: onEdit ? () => onEdit(draft) : undefined } +} diff --git a/client/src/components/Roadtrip/useLeaveMode.test.ts b/client/src/components/Roadtrip/useLeaveMode.test.ts new file mode 100644 index 000000000..e1aab1b17 --- /dev/null +++ b/client/src/components/Roadtrip/useLeaveMode.test.ts @@ -0,0 +1,203 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useLeaveMode, type LeaveSubject } from './useLeaveMode' +import { useAuthStore } from '../../store/authStore' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useTripStore } from '../../store/tripStore' +import { buildAssignment, buildPlace } from '../../../tests/helpers/factories' +import { server } from '../../../tests/helpers/msw/server' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' + +// FE-ROADTRIP-LEAVE-001..014: the part of the stay dialog that is about a time set to +// leave a stop, and how it takes that time off the visit. + +type AddToast = NonNullable + +const visit = { + id: 102, + day_id: 11, + place_id: 202, + order_index: 1, + assignment_time: '09:00', + assignment_end_time: '14:00', + place: { id: 202, name: 'Bremen', place_time: '09:00', end_time: '14:00' }, +} + +const subject: LeaveSubject = { leaveAt: '14:00', arrival: '10:00', departure: '14:00', assignmentId: 102, dayId: 11 } + +let setAssignmentTimes: ReturnType +let updatePlace: ReturnType +let refreshDays: ReturnType +let addToast: ReturnType> + +beforeEach(() => { + resetAllStores() + setAssignmentTimes = vi.fn(async () => undefined) + updatePlace = vi.fn(async () => undefined) + refreshDays = vi.fn(async () => undefined) + addToast = vi.fn(() => 0) + window.__addToast = addToast + seedStore(useAuthStore, { user: { id: 1, role: 'user' } }) + seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, assignments: { '11': [visit] } }) + useTripStore.setState({ setAssignmentTimes, updatePlace, refreshDays } as never) +}) + +afterEach(() => { + delete window.__addToast +}) + +describe('useLeaveMode', () => { + it('FE-ROADTRIP-LEAVE-001: reads the time the stop is left at and the stay that makes', () => { + const { result } = renderHook(() => useLeaveMode(subject)) + expect(result.current).toMatchObject({ until: '14:00', minutes: 240, removing: false }) + expect(result.current.remove).toBeTypeOf('function') + }) + + it('FE-ROADTRIP-LEAVE-002: an ordinary stay is nothing for it to answer', () => { + const { result } = renderHook(() => useLeaveMode({ ...subject, leaveAt: null })) + expect(result.current.until).toBeNull() + expect(renderHook(() => useLeaveMode(null)).result.current.until).toBeNull() + }) + + it('FE-ROADTRIP-LEAVE-003: removing clears the End through the time write and keeps the Start', async () => { + const { result } = renderHook(() => useLeaveMode(subject)) + await act(async () => { await result.current.remove!() }) + expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null }) + // The day is read back as the place form reads it after the same write. + expect(refreshDays).toHaveBeenCalledWith(4) + expect(result.current.until).toBeNull() + }) + + it('FE-ROADTRIP-LEAVE-004: a refused write says so and leaves the end time standing', async () => { + setAssignmentTimes.mockRejectedValueOnce(new Error('Denied')) + const { result } = renderHook(() => useLeaveMode(subject)) + await act(async () => { await result.current.remove!() }) + expect(addToast).toHaveBeenCalledWith('Denied', 'error', undefined) + expect(result.current.until).toBe('14:00') + expect(refreshDays).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-LEAVE-005: nothing to remove for a traveller who may not change the day', () => { + usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } }) + seedStore(useTripStore, { trip: { id: 4, user_id: 2 } }) + const reader = renderHook(() => useLeaveMode(subject)) + expect(reader.result.current.remove).toBeUndefined() + reader.unmount() + // Nor for a visit still waiting for the id the server gives it. + seedStore(useTripStore, { trip: { id: 4, user_id: 1 } }) + expect(renderHook(() => useLeaveMode({ ...subject, assignmentId: -5 })).result.current.remove).toBeUndefined() + }) + + it('FE-ROADTRIP-LEAVE-006: an End only the place carries is taken off the place', async () => { + // The visit's own time write cannot reach it, so clearing that alone would change nothing. + seedStore(useTripStore, { + places: [{ id: 202, name: 'Bremen', end_time: '14:00' }], + assignments: { '11': [{ ...visit, assignment_end_time: null }] }, + }) + const { result } = renderHook(() => useLeaveMode(subject)) + expect(result.current.until).toBe('14:00') + await act(async () => { await result.current.remove!() }) + expect(setAssignmentTimes).not.toHaveBeenCalled() + expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null }) + expect(result.current.until).toBeNull() + }) + + it.each([['the same', '14:00'], ['another', '11:00']])( + 'FE-ROADTRIP-LEAVE-009: with %s End on the place as well, removing clears both', + async (_label, placeEnd) => { + // A migration copied the place's End onto every visit, so older trips carry it + // twice. Clearing only the visit's left the stop leaving at the place's. + seedStore(useTripStore, { places: [{ id: 202, name: 'Bremen', end_time: placeEnd }] }) + const { result } = renderHook(() => useLeaveMode(subject)) + await act(async () => { await result.current.remove!() }) + expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null }) + expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null }) + expect(result.current.until).toBeNull() + }, + ) + + it('FE-ROADTRIP-LEAVE-010: not offered when another visit of the place still leans on its End', () => { + // Taking the place's End would take that visit's too, and keeping it keeps this one's. + const other = { id: 150, day_id: 12, place_id: 202, order_index: 0, assignment_time: null, assignment_end_time: null } + seedStore(useTripStore, { + places: [{ id: 202, name: 'Bremen', end_time: '14:00' }], + assignments: { '11': [visit], '12': [other] }, + }) + const leaning = renderHook(() => useLeaveMode(subject)) + expect(leaning.result.current.until).toBe('14:00') + expect(leaning.result.current.remove).toBeUndefined() + leaning.unmount() + // One with an End of its own does not care what the place carries. + seedStore(useTripStore, { assignments: { '11': [visit], '12': [{ ...other, assignment_end_time: '16:00' }] } }) + expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeTypeOf('function') + }) + + it('FE-ROADTRIP-LEAVE-011: not offered to a traveller who may not edit the place its End sits on', () => { + usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } }) + seedStore(useTripStore, { trip: { id: 4, user_id: 2 }, places: [{ id: 202, name: 'Bremen', end_time: '14:00' }] }) + expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeUndefined() + }) + + it('FE-ROADTRIP-LEAVE-012: through the real store the stop has no End left afterwards', async () => { + // Nothing stubbed but the day read back: the visit's time write and the place write + // both go out, and the End the road trip reads for the visit is gone. + resetAllStores() + seedStore(useAuthStore, { user: { id: 1, role: 'user' } }) + const pool = buildPlace({ id: 202, trip_id: 4, name: 'Bremen', place_time: null, end_time: '14:00' }) + const stored = buildAssignment({ + id: 102, day_id: 11, place_id: 202, assignment_time: '09:00', assignment_end_time: '14:00', + place: { ...pool, place_time: '09:00', end_time: '14:00' }, + }) + seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, places: [pool], assignments: { '11': [stored] } }) + useTripStore.setState({ refreshDays } as never) + server.use( + http.put('/api/trips/4/assignments/102/time', () => + HttpResponse.json({ assignment: { ...stored, assignment_end_time: null, place: { ...pool, place_time: '09:00' } } })), + http.put('/api/trips/4/places/202', () => HttpResponse.json({ place: { ...pool, end_time: null } })), + ) + const { result } = renderHook(() => useLeaveMode(subject)) + await act(async () => { await result.current.remove!() }) + const after = useTripStore.getState().assignments['11']![0]! + expect(after.assignment_end_time ?? after.place?.end_time ?? null).toBeNull() + expect(result.current.until).toBeNull() + expect(addToast).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-LEAVE-013: a refused write shows what the server said, not the request library', async () => { + setAssignmentTimes.mockRejectedValueOnce({ + message: 'Request failed with status code 404', + response: { status: 404, data: { error: 'Assignment not found' } }, + }) + const { result } = renderHook(() => useLeaveMode(subject)) + await act(async () => { await result.current.remove!() }) + expect(addToast).toHaveBeenCalledWith('Assignment not found', 'error', undefined) + // With nothing from the server, the translated fallback rather than the English boilerplate. + setAssignmentTimes.mockRejectedValueOnce({ message: 'Request failed with status code 500', response: { status: 500 } }) + await act(async () => { await result.current.remove!() }) + expect(addToast).toHaveBeenLastCalledWith('common.unknownError', 'error', undefined) + }) + + it('FE-ROADTRIP-LEAVE-014: hands on the finding of a missed time and a day that ends first', () => { + expect(renderHook(() => useLeaveMode({ ...subject, arrival: '14:30', departure: '14:30', missedBy: 30 })).result.current) + .toMatchObject({ until: '14:00', minutes: 0, missedBy: 30, dayEndsFirst: false }) + expect(renderHook(() => useLeaveMode({ ...subject, leaveAt: '20:00', arrival: '10:30', departure: '18:00' })).result.current) + .toMatchObject({ until: '20:00', minutes: null, missedBy: null, dayEndsFirst: true }) + }) + + it('FE-ROADTRIP-LEAVE-008: a visit gone from the store by the time of the tap is left alone', async () => { + const { result } = renderHook(() => useLeaveMode(subject)) + const remove = result.current.remove! + act(() => seedStore(useTripStore, { assignments: { '11': [] } })) + await act(async () => { await remove() }) + expect(setAssignmentTimes).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-LEAVE-007: opened on another visit, the removal of the last one is forgotten', async () => { + const { result, rerender } = renderHook(({ s }) => useLeaveMode(s), { initialProps: { s: subject } }) + await act(async () => { await result.current.remove!() }) + expect(result.current.until).toBeNull() + rerender({ s: { ...subject, assignmentId: 103, leaveAt: '16:00' } }) + await waitFor(() => expect(result.current.until).toBe('16:00')) + }) +}) diff --git a/client/src/components/Roadtrip/useLeaveMode.ts b/client/src/components/Roadtrip/useLeaveMode.ts new file mode 100644 index 000000000..96f33ad91 --- /dev/null +++ b/client/src/components/Roadtrip/useLeaveMode.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from '../../i18n/TranslationContext' +import { useCanDo } from '../../store/permissionsStore' +import { useTripStore } from '../../store/tripStore' +import { getApiErrorMessage } from '../../types' +import { useToast } from '../shared/Toast' +import { readStay } from './stayReading' + +/** The visit a stay dialog is open on, as far as a time set to leave it goes. */ +export interface LeaveSubject { + leaveAt?: string | null + arrival?: string | null + departure?: string | null + /** How many minutes after the time the drive gets here, as the schedule found it. */ + missedBy?: number | null + assignmentId?: number + /** The day the visit is stored on, which is where the store files it. */ + dayId?: number +} + +export interface LeaveMode { + /** The time the stop is left at, or null when the dialog is about an ordinary stay. */ + until: string | null + /** How long that makes the stay, when the drive says when the stop is reached. */ + minutes: number | null + /** The drive gets here this many minutes after the time, and leaves on arrival. */ + missedBy: number | null + /** The travel hours close the day before the time comes, see `StayReading`. */ + dayEndsFirst: boolean + /** + * Takes the end time off the visit. Absent for a traveller who may not make the writes + * that takes, and for an End that cannot go without taking another visit's with it: a + * button that changes nothing is worse than none. + */ + remove?: () => Promise + removing: boolean +} + +/** + * The half of the stay dialog that is about a time set to leave a stop. + * + * Shared by the desktop dialog and the phone sheet, so the two cannot answer differently. + * A stop the traveller leaves at a set time has no length to choose: it is stood at until + * then. So the dialog says when the drive leaves and what stay that makes, and offers to + * take the end time off the visit, after which the stay is a choice again. + * + * The End a visit is left at is its own, or else the one its place carries: the server + * reads COALESCE(assignment_end_time, places.end_time), and so do mergeAssignmentPlace and + * the road trip's stops. A migration copied the place's End onto every visit there was, + * so on older trips both hold it, and clearing only the visit's column left the stop + * leaving at the same time. Removing therefore clears both. The place's End is only taken + * when no other visit of the place still leans on it; when one does, the removal is not + * offered, because it could not take the End off this visit alone. + * + * The visit's own End is cleared through its time write, the one the place form saves + * Start and End with. That write sets both, so the visit's Start goes along as it stands. + */ +export function useLeaveMode(subject: LeaveSubject | null): LeaveMode { + const { t } = useTranslation() + const toast = useToast() + const can = useCanDo() + const trip = useTripStore(s => s.trip) + const [removed, setRemoved] = useState(false) + const [removing, setRemoving] = useState(false) + const assignmentId = subject?.assignmentId + const dayId = subject?.dayId + const leaveAt = subject?.leaveAt ?? null + const ownEnd = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.assignment_end_time ?? null) + const placeId = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.place_id ?? null) + const placeEnd = useTripStore(s => (placeId === null ? null : s.places.find(p => p.id === placeId)?.end_time ?? null)) + const leanedOn = useTripStore(s => placeEnd !== null && Object.values(s.assignments).some(list => + list.some(a => a.place_id === placeId && a.id !== assignmentId && a.assignment_end_time == null))) + + // Opened on another visit, or on one whose end time changed: nothing was removed there. + useEffect(() => { setRemoved(false) }, [assignmentId, leaveAt]) + + const remove = useCallback(async () => { + const store = useTripStore.getState() + const visit = store.assignments[String(dayId)]?.find(a => a.id === assignmentId) + // Gone between the render that offered this and the tap. + if (!trip || !visit || dayId == null) return + const place = store.places.find(p => p.id === visit.place_id) + setRemoving(true) + try { + if (visit.assignment_end_time != null) { + await store.setAssignmentTimes(trip.id, dayId, visit.id, { place_time: visit.assignment_time ?? null, end_time: null }) + } + if (place?.end_time != null) await store.updatePlace(trip.id, place.id, { end_time: null }) + setRemoved(true) + // As the place form does after the same write: a Start can reorder the day on the + // server, and only the day read back shows it. + await store.refreshDays(trip.id) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('common.unknownError'))) + } finally { + setRemoving(false) + } + }, [trip, dayId, assignmentId, toast, t]) + + const reading = readStay({ dwellMinutes: null, leaveAt }, subject) + const allowed = !!trip && assignmentId != null && assignmentId > 0 && (ownEnd !== null || placeEnd !== null) + && (ownEnd === null || can('day_edit', trip)) + && (placeEnd === null || (can('place_edit', trip) && !leanedOn)) + return { + until: removed ? null : reading.until, + minutes: reading.minutes, + missedBy: subject?.missedBy ?? null, + dayEndsFirst: reading.dayEndsFirst === true, + remove: allowed ? remove : undefined, + removing, + } +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 4227dcde8..02f986fbb 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -33,6 +33,10 @@ interface StopSpec { incoming?: string | null stopType?: string | null noCoords?: boolean + /** The visit's own End. */ + end?: string | null + /** An End only the place carries, for a visit that has none of its own. */ + placeEnd?: string | null } function day(id: number, number: number, extra: Partial = {}): Day { @@ -45,6 +49,7 @@ function assignment(spec: StopSpec, order: number): Assignment { place_id: spec.id * 10, order_index: order, assignment_time: spec.time ?? null, + assignment_end_time: spec.end ?? null, leg_transport_mode: spec.legMode ?? null, incoming_leg_transport_mode: spec.incoming ?? null, place: { @@ -53,6 +58,9 @@ function assignment(spec: StopSpec, order: number): Assignment { lat: spec.noCoords ? null : spec.at[0], lng: spec.noCoords ? null : spec.at[1], place_time: null, + // Folded in the way the store keeps a visit (mergeAssignmentPlace): its own End, + // or else the place's. + end_time: spec.end ?? spec.placeEnd ?? null, duration_minutes: spec.dwell ?? null, stop_type: spec.stopType ?? null, }, @@ -764,6 +772,80 @@ describe('useRoadtripRoutes', () => { }) }) +describe('a visit End on the road trip', () => { + /** An hour from Hamburg to Lueneburg and another on to Berlin. */ + const hourly = () => ({ + coordinates: [HAMBURG, LUENEBURG, BERLIN], + distance: 200000, + duration: 7200, + legs: [{ distance: 100000, duration: 3600, text: '' }, { distance: 100000, duration: 3600, text: '' }], + }) + + async function drive(stops: StopSpec[]) { + calculateRouteWithLegs.mockResolvedValue(hourly()) + const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops))) + await waitFor(() => expect(result.current.loading).toBe(false)) + await waitFor(() => expect(result.current.days).toHaveLength(1)) + return result.current.days[0] + } + + afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }))) + + it('FE-ROADTRIP-ROUTES-030: the drive leaves a stop at its End, not after its stay', async () => { + const d = await drive([ + { id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, + { id: 2, at: LUENEBURG, end: '14:00', dwell: 30 }, + { id: 3, at: BERLIN }, + ]) + expect(d.stops[1].leaveAt).toBe('14:00') + expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' }) + expect(d.schedule.entries[2].arrival).toBe('15:00') + expect(d.schedule.warnings).toEqual([]) + }) + + it('FE-ROADTRIP-ROUTES-031: an End the place carries counts for a visit without its own', async () => { + const d = await drive([ + { id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, + { id: 2, at: LUENEBURG, placeEnd: '13:00', dwell: 30 }, + { id: 3, at: BERLIN }, + ]) + expect(d.schedule.entries[1].departure).toBe('13:00') + expect(d.schedule.entries[2].arrival).toBe('14:00') + }) + + it('FE-ROADTRIP-ROUTES-032: Start and End together make the stay the time between them', async () => { + const d = await drive([ + { id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, + { id: 2, at: LUENEBURG, time: '10:00', end: '14:00', dwell: 60 }, + { id: 3, at: BERLIN }, + ]) + expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true }) + expect(d.schedule.entries[2].arrival).toBe('15:00') + }) + + it('FE-ROADTRIP-ROUTES-033: a stop reached after its End says so instead of leaving quietly', async () => { + const d = await drive([ + { id: 1, at: HAMBURG, time: '13:30', dwell: 0 }, + { id: 2, at: LUENEBURG, end: '14:00', dwell: 30 }, + { id: 3, at: BERLIN }, + ]) + expect(d.schedule.entries[1]).toMatchObject({ arrival: '14:30', departure: '14:30' }) + expect(d.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]) + }) + + it('FE-ROADTRIP-ROUTES-034: daily travel times spend the hours until the End as well', async () => { + act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '20:00' } })) + const d = await drive([ + { id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, + { id: 2, at: LUENEBURG, end: '14:00', dwell: 30 }, + { id: 3, at: BERLIN }, + ]) + expect(d.automaticSchedule).toBe(true) + expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' }) + expect(d.schedule.entries[2].arrival).toBe('15:00') + }) +}) + vi.mock('../../hooks/useRoadtripSettings', () => ({ useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)), })) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 2637f3d94..f185063c0 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -87,6 +87,9 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda lat: p.lat, lng: p.lng, time: a.assignment_time ?? p.place_time ?? null, + // The visit's End is when the drive leaves it. Unlike a check-out it is the + // traveller's own statement about this stop, and the stay then runs until it. + leaveAt: a.assignment_end_time ?? p.end_time ?? null, checkInTime: stay?.check_in ?? null, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, endDay: a.end_day === true, diff --git a/client/src/components/shared/DocumentProviderIcons.test.tsx b/client/src/components/shared/DocumentProviderIcons.test.tsx new file mode 100644 index 000000000..c5ee09395 --- /dev/null +++ b/client/src/components/shared/DocumentProviderIcons.test.tsx @@ -0,0 +1,156 @@ +// FE-DOCSYNC-ICONS-001 to FE-DOCSYNC-ICONS-014 + +/** + * The document providers' marks, and TREK's own. + * + * These are static files, so the only things worth holding still are the ones a + * person would notice if they slipped: a provider the server seeds but the + * client has no glyph for (the row renders nothing at all), a mark that keeps a + * brand colour and so stays black on a dark background, and the sizing contract + * the callers rely on: `size` for the bare lucide-sized rows, a `className` + * everywhere the surrounding Tailwind box decides how big the glyph is. + */ +import { describe, it, expect } from 'vitest' +import { DOCUMENT_PROVIDER_IDS } from '@trek/shared' +import { render } from '../../../tests/helpers/render' +import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons' +import TrekIcon from './TrekIcon' + +const marks = Object.entries(DOCUMENT_PROVIDER_ICONS) + +/** The svg plus everything drawn inside it. */ +function allElements(svg: SVGElement): Element[] { + return [svg, ...Array.from(svg.querySelectorAll('*'))] +} + +function renderMark(Icon: (typeof marks)[number][1], props: Record = {}): SVGElement { + const { container } = render() + const svg = container.querySelector('svg') + if (!svg) throw new Error('no svg rendered') + return svg as unknown as SVGElement +} + +describe('DOCUMENT_PROVIDER_ICONS', () => { + it('FE-DOCSYNC-ICONS-001: carries exactly one mark per provider the server seeds', () => { + // A provider seeded without a glyph here renders an empty slot in the + // connect modal and the addon shelf; a glyph with no provider is dead code. + expect(Object.keys(DOCUMENT_PROVIDER_ICONS).sort()).toEqual([...DOCUMENT_PROVIDER_IDS].sort()) + }) + + it('FE-DOCSYNC-ICONS-002: resolves each id the way the panels look one up', () => { + for (const id of DOCUMENT_PROVIDER_IDS) { + expect(typeof DOCUMENT_PROVIDER_ICONS[id], id).toBe('function') + } + }) + + it('FE-DOCSYNC-ICONS-003: renders an svg for every provider', () => { + for (const [id, Icon] of marks) { + const { container } = render() + expect(container.querySelector('svg'), id).not.toBeNull() + } + }) + + it('FE-DOCSYNC-ICONS-004: draws only in currentColor, so the mark inverts with the theme', () => { + const hex = /#[0-9a-f]{3}/i + + for (const [id, Icon] of marks) { + const svg = renderMark(Icon) + const painted: string[] = [] + + for (const el of allElements(svg)) { + for (const attr of Array.from(el.attributes)) { + expect(attr.value, `${id} ${attr.name}`).not.toMatch(hex) + if (attr.name === 'fill' || attr.name === 'stroke') painted.push(attr.value) + } + } + + expect(painted.length, id).toBeGreaterThan(0) + for (const value of painted) expect(['currentColor', 'none'], id).toContain(value) + expect(painted, id).toContain('currentColor') + } + }) + + it('FE-DOCSYNC-ICONS-005: keeps a viewBox, so the glyph scales instead of cropping', () => { + for (const [id, Icon] of marks) { + expect(renderMark(Icon).getAttribute('viewBox'), id).toMatch(/^0 0 \d+ \d+$/) + } + }) + + it('FE-DOCSYNC-ICONS-006: stays out of the accessibility tree, the provider name is next to it', () => { + for (const [id, Icon] of marks) { + expect(renderMark(Icon).getAttribute('aria-hidden'), id).toBe('true') + } + }) + + it('FE-DOCSYNC-ICONS-007: sizes itself to 20px when nothing says otherwise', () => { + for (const [id, Icon] of marks) { + const svg = renderMark(Icon) + expect(svg.getAttribute('width'), id).toBe('20') + expect(svg.getAttribute('height'), id).toBe('20') + } + }) + + it('FE-DOCSYNC-ICONS-008: honours an explicit size', () => { + for (const [id, Icon] of marks) { + const svg = renderMark(Icon, { size: 32 }) + expect(svg.getAttribute('width'), id).toBe('32') + expect(svg.getAttribute('height'), id).toBe('32') + } + }) + + it('FE-DOCSYNC-ICONS-009: drops its own width and height once a className sizes it', () => { + // Callers pass utility classes (`h-6 w-6`); a width attribute would win over + // them on some of these and leave a 20px glyph in a 24px box. + for (const [id, Icon] of marks) { + const svg = renderMark(Icon, { className: 'h-6 w-6', size: 32 }) + expect(svg.getAttribute('width'), id).toBeNull() + expect(svg.getAttribute('height'), id).toBeNull() + } + }) + + it('FE-DOCSYNC-ICONS-010: puts the caller className on the svg', () => { + for (const [id, Icon] of marks) { + expect(renderMark(Icon, { className: 'h-6 w-6 text-content' }).getAttribute('class'), id).toBe( + 'h-6 w-6 text-content', + ) + } + }) +}) + +describe('TrekIcon', () => { + it('FE-DOCSYNC-ICONS-011: renders TREK\u2019s app icon in currentColor', () => { + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).not.toBeNull() + expect(svg!.getAttribute('viewBox')).toBe('0 0 512 512') + expect(svg!.querySelectorAll('path[fill="currentColor"]').length).toBeGreaterThan(0) + }) + + it('FE-DOCSYNC-ICONS-012: takes a className, which is the only thing that sizes it', () => { + const { container } = render() + const svg = container.querySelector('svg')! + + expect(svg.getAttribute('class')).toBe('h-6 w-6 text-content') + expect(svg.getAttribute('width')).toBeNull() + expect(svg.getAttribute('height')).toBeNull() + }) + + it('FE-DOCSYNC-ICONS-013: forwards the rest of its svg props', () => { + const { container } = render() + const svg = container.querySelector('svg')! + + expect(svg.getAttribute('data-testid')).toBe('trek-icon') + expect(svg.getAttribute('role')).toBe('img') + }) + + it('FE-DOCSYNC-ICONS-014: is decorative by default and can be unhidden by the caller', () => { + const { container } = render() + expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true') + + const labelled = render() + const svg = labelled.container.querySelector('svg')! + expect(svg.getAttribute('aria-hidden')).toBe('false') + expect(svg.getAttribute('aria-label')).toBe('TREK') + }) +}) diff --git a/client/src/components/shared/DocumentProviderIcons.tsx b/client/src/components/shared/DocumentProviderIcons.tsx new file mode 100644 index 000000000..7349174db --- /dev/null +++ b/client/src/components/shared/DocumentProviderIcons.tsx @@ -0,0 +1,137 @@ +import React from 'react' + +/** + * The document providers' marks, redrawn as single-colour glyphs. + * + * `currentColor` rather than the brands' own palettes, and that is the point: + * they sit in a row of lucide icons and in a list of addon toggles, where a + * full-colour badge among line icons reads as the one thing that is switched + * on. Following the text colour also means they invert with the theme without a + * second asset: black on light, white on dark. + * + * Not in `public/brands/` beside AirTrail and Dawarich, because those are the + * files each project ships, served unmodified. These are deliberately modified: + * a recoloured logo is a different logo, and the trade is made knowingly here + * for legibility in a monochrome row. + */ + +interface GlyphProps { + size?: number + className?: string +} + +function frame(size: number, className: string | undefined) { + return { + width: className ? undefined : size, + height: className ? undefined : size, + className, + viewBox: '0 0 512 512', + fill: 'none' as const, + xmlns: 'http://www.w3.org/2000/svg', + style: { flexShrink: 0, display: 'block' as const }, + 'aria-hidden': true, + } +} + +/** Paperless-ngx: the leaf, with the shoot curling out of its stem. */ +export function PaperlessIcon({ size = 20, className }: GlyphProps): React.ReactElement { + return ( + + + + + + + ) +} + +/** Papra: a sheet with a folded corner and its lines of text. */ +export function PapraIcon({ size = 20, className }: GlyphProps): React.ReactElement { + return ( + + + + + + + + ) +} + +/** Nextcloud: the three rings. */ +export function NextcloudIcon({ size = 20, className }: GlyphProps): React.ReactElement { + return ( + + + + + + ) +} + +/** OpenCloud: the open hexagon with the junction inside it. */ +export function OpenCloudIcon({ size = 20, className }: GlyphProps): React.ReactElement { + return ( + + + + + + ) +} + +/** Synology Drive: the wordmark's own glyph, as the addon list already draws it. */ +export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.ReactElement { + return ( + + + + ) +} + +/** Keyed by the provider ids in `document_providers`. */ +export const DOCUMENT_PROVIDER_ICONS: Record> = { + paperless: PaperlessIcon, + papra: PapraIcon, + nextcloud: NextcloudIcon, + opencloud: OpenCloudIcon, + synologydrive: SynologyDriveIcon, +} diff --git a/client/src/components/shared/TrekIcon.tsx b/client/src/components/shared/TrekIcon.tsx new file mode 100644 index 000000000..e10775f68 --- /dev/null +++ b/client/src/components/shared/TrekIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' + +/** + * TREK's square app icon, from public/icons/icon.svg. The wordmark with the API + * pill is `TrekMark`. + * + * Inlined rather than loaded as an so it takes `currentColor` and follows + * the theme like the provider glyphs it sits next to in the document-sync flow. + * The path is the icon file's, unchanged. + */ +export default function TrekIcon({ className, ...props }: SVGProps) { + return ( + + + + + + ) +} diff --git a/client/src/index.css b/client/src/index.css index ff326b7f3..56b6f5440 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -506,6 +506,47 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti will-change: transform, opacity; } +/* Dokumentenabgleich: die aktive Bahn laeuft, WAEHREND ein Lauf laeuft. + Bewegung als Antwort auf eine Handlung, nicht als Dekoration: sie hoert auf, + sobald der Lauf durch ist, und sagt in der Zwischenzeit, in welche Richtung + gerade etwas unterwegs ist. */ +@keyframes trek-docsync-lane { + from { background-position: 0 0; } + to { background-position: 16px 0; } +} +.trek-docsync-lane-run { + background-image: repeating-linear-gradient( + 90deg, + currentColor 0 5px, + transparent 5px 16px + ); + animation: trek-docsync-lane 700ms linear infinite; +} +.trek-docsync-lane-run-rev { + animation-direction: reverse; +} + +/* Die abgeschaltete Richtung bekommt eine Schraffur, dasselbe Zeichen, das eine + gesperrte Flaeche ueberall traegt. Es liest sich als "hier geht nichts durch", + ohne dass die Bahn verschwindet und die Zeile zusammenklappt. */ +.trek-docsync-lane-off { + background-image: repeating-linear-gradient( + -45deg, + var(--border-primary) 0 5px, + transparent 5px 20px + ); +} + +/* Dieselbe Schraffur im Telefon-Schema: die mobilen Flaechen kennen die + Desktop-Randfarbe nicht, und eine Fremdfarbe faellt dort sofort auf. */ +.trek-docsync-lane-off-m { + background-image: repeating-linear-gradient( + -45deg, + var(--m-rowbr) 0 5px, + transparent 5px 20px + ); +} + /* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */ @keyframes trek-drawer-enter { from { opacity: 0; transform: translateY(100%); } @@ -595,6 +636,11 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti .trek-lowfuel { animation: none; } + /* Die Bahn bleibt sichtbar, sie laeuft nur nicht mehr. Die Aussage + "hier ist gerade etwas unterwegs" traegt auch im Stillstand. */ + .trek-docsync-lane-run { + animation: none; + } .trek-skeleton { animation: none; background: var(--bg-tertiary); diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index b5eaf61b7..99ef39e61 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -10,6 +10,7 @@ import { } from 'lucide-react' import DawarichIcon from '../../../components/shared/DawarichIcon' import AirTrailIcon from '../../../components/shared/AirTrailIcon' +import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons' import MToggle from '../../components/MToggle' import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' @@ -37,6 +38,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) { const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, + ...DOCUMENT_PROVIDER_ICONS, } interface Addon { @@ -121,8 +123,9 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac refreshGlobalAddons() // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf - // up with providers the database has long since turned off. - if (addon.id === 'journey') await loadAddons() + // up with providers the database has long since turned off. Documents does + // the same to the document providers. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() toast.success(t('admin.addons.toast.updated')) } @@ -152,6 +155,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac } const photoProviderAddons = addons.filter(isPhotoProviderAddon) + const documentProviderAddons = addons.filter(a => a.type === 'document_provider') const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') @@ -162,6 +166,16 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac enabled: provider.enabled, toggle: () => handleTogglePhotoProvider(provider), })) + // No credential form under these, unlike the photo providers: a document + // connection belongs to a trip and is entered there. The admin only decides + // whether a provider may be offered at all. + const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })) if (loading) { return ( @@ -207,6 +221,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac onToggle={onToggleBagTracking} /> )} + {addon.id === 'documents' && addon.enabled && } {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( <> {COLLAB_SUB_FEATURES.map(feat => ( @@ -234,23 +249,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
{/* Memories providers as sub-items under Journey addon — only while it is on */} - {addon.id === 'journey' && addon.enabled && providerOptions.length > 0 && ( - <> - {providerOptions.map(provider => { - const ProviderIcon = PROVIDER_ICONS[provider.key] - return ( - - ) - })} - - )} + {addon.id === 'journey' && addon.enabled && }
))} @@ -332,6 +331,24 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) { ) } +/** A provider shelf under its addon: Journey's photo providers, Documents' document providers. */ +function MProviderShelf({ options }: { options: ProviderOption[] }) { + return ( + <> + {options.map(provider => ( + + ))} + + ) +} + interface MSubRowProps { icon?: typeof Luggage providerIcon?: ComponentType<{ size?: number }> diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index a0421a1ba..92faf9be3 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -23,9 +23,10 @@ function linkHost(url: string): string { // Hero chrome sits on a photo/gradient — fixed white/black scrims in both themes. const HERO_CAT_CHIP = - 'relative inline-flex items-center gap-1 rounded-full bg-[rgba(255,255,255,.9)] px-[10px] py-[3px] font-geist text-[0.59375rem] font-extrabold text-[#101013]' // theme-lint-disable -const HERO_CLOSE = - 'absolute right-[14px] top-[14px] z-[1] flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable + 'inline-flex min-w-0 items-center gap-1 rounded-full bg-[rgba(255,255,255,.9)] px-[10px] py-[3px] font-geist text-[0.59375rem] font-extrabold text-[#101013]' // theme-lint-disable +// Close and the cover controls beside it. +const HERO_BTN = + 'flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable interface MCollPlaceSheetProps { place: CollectionPlace | null @@ -158,7 +159,7 @@ export default function MCollPlaceSheet({ <> {/* Hero: auto cover (photo when available, the design gradient otherwise) */}
{cover && ( @@ -167,43 +168,42 @@ export default function MCollPlaceSheet({
)} - {held.category?.name && ( - - {held.category.name} - - )} - - {canEdit && onUploadImage && ( -
- - {held.image_url && !imgBusy && ( - + {/* Chip and cover controls share the top row, in flow: the name always + starts below the buttons, chip or not, and a long category name + ellipsizes against them instead of running underneath. */} +
+ {held.category?.name && ( + + + {held.category.name} + + )} + {/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */} +
+ {canEdit && onUploadImage && ( + <> + + {held.image_url && !imgBusy && ( + + )} + + )} - +
- )} -
{held.name}
+
+
{held.name}
diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index b59764b2e..cffc7685c 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ComponentType, type ReactNode } from import { findFocusDayId } from '../../../components/Planner/today' import { CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, - Plane, Plus, Rows3, Route, SlidersHorizontal, Trash2, Upload, + FolderSync, Plane, Plus, Rows3, Route, SlidersHorizontal, Trash2, Upload, } from 'lucide-react' import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner' import { pickDockTabs } from './dockTabs' @@ -19,7 +19,10 @@ import { stageOf } from '../../../components/Roadtrip/roadtripRowModel' import { badgeLabel, distanceBadge } from './roadtrip/stageBadges' import type { CorridorReach } from '../../../components/Roadtrip/corridorSearchModel' import { useSettingsStore } from '../../../store/settingsStore' -import type { Day } from '../../../types' +import { useAuthStore } from '../../../store/authStore' +import { canManageDocSync } from '../../../components/Files/docsync/useDocSync' +import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered' +import type { Day, Trip } from '../../../types' /** * Mobile trip screen frame. Owns the chrome the design shares across every @@ -116,6 +119,7 @@ export interface MTripShellApi { exportCostsCsvSignal: number uploadFilesSignal: number openFilesTrashSignal: number + openDocSyncSignal: number } /** @@ -226,6 +230,7 @@ export default function MTripShell({ const [exportCostsCsvSignal, setExportCostsCsvSignal] = useState(0) const [uploadFilesSignal, setUploadFilesSignal] = useState(0) const [openFilesTrashSignal, setOpenFilesTrashSignal] = useState(0) + const [openDocSyncSignal, setOpenDocSyncSignal] = useState(0) // The mobile plan is single-day: make sure a day is active once days arrive. // Only seed once so an intentional deselect elsewhere is not fought. Open on @@ -378,6 +383,7 @@ export default function MTripShell({ listsTab, setListsTab, collabTab, setCollabTab, transportsCompact, bookingsCompact, addExpenseSignal, exportCostsCsvSignal, uploadFilesSignal, openFilesTrashSignal, + openDocSyncSignal, } // Splash — same gate as the desktop page, in the mobile design language. @@ -672,6 +678,7 @@ export default function MTripShell({ {trTab === 'dateien' && (
} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} /> + setOpenDocSyncSignal(s => s + 1)} /> setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]"> @@ -769,6 +776,23 @@ function PrimaryPill({ label, onClick, icon }: { label: string; onClick: () => v ) } +/** + * The Files header's sync button, only where there is something behind it. + * + * A component of its own so the question is asked while the Files header is + * up, not on every tab the shell renders. + */ +function DocSyncButton({ tripId, trip, label, onOpen }: { tripId: number; trip: Trip; label: string; onOpen: () => void }) { + const user = useAuthStore(s => s.user) + const offered = useDocSyncOffered(tripId, canManageDocSync(user, trip)) + if (!offered) return null + return ( + + + + ) +} + /** 40px list-density toggle on the transports/bookings headers. */ function CompactToggle({ active, onToggle, label }: { active: boolean; onToggle: () => void; label: string }) { return ( diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx index 25f671f95..fd4e8c759 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ArrowRight, Minus, Plus } from 'lucide-react' +import { AlertTriangle, ArrowRight, Minus, Plus } from 'lucide-react' import MSheet from '../../../components/MSheet' import MChip from '../../../components/MChip' import type { MTripSheetsProps } from '../MTripShell' @@ -7,9 +7,11 @@ import { useTranslation } from '../../../../i18n' import { useSettingsStore } from '../../../../store/settingsStore' import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel' import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel' +import { locateStop, missedLeaveOf } from '../../../../components/Roadtrip/stayReading' +import { useLeaveMode, type LeaveMode } from '../../../../components/Roadtrip/useLeaveMode' import { formatClockTime } from '../../../../utils/formatters' import { FormSheetHeader } from '../sheets/PlSheetChrome' -import { INNER_CLS } from '../sheets/MTripSheetUi' +import { INNER_CLS, displayTime } from '../sheets/MTripSheetUi' /** What the chain hands over when a stop's stay is tapped. */ interface RtStaySheetPayload { @@ -102,12 +104,24 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { */ const stage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId) const stopPlaceId = stop?.placeId + // The visit itself when the stop sheet named it, read off the same stage: its end time + // decides whether there is a stay to choose here at all. + const located = stage && stop?.assignmentId != null ? locateStop([stage], stop.assignmentId) : null const arrival = useMemo(() => { + if (located) return located.entry?.arrival ?? null if (!stage || stopPlaceId == null) return null const index = stage.stops.findIndex(s => s.placeId === stopPlaceId) if (index === -1) return null return stage.schedule.entries[index]?.arrival ?? null - }, [stage, stopPlaceId]) + }, [located, stage, stopPlaceId]) + const leave = useLeaveMode(located && { + leaveAt: located.stop.leaveAt, + arrival: located.entry?.arrival, + departure: located.entry?.departure, + missedBy: missedLeaveOf(located.day, located.index), + assignmentId: located.stop.assignmentId, + dayId: located.stop.ownerDayId, + }) // What the stay does to this stop: the arrival is fixed by the drive, the departure is // the one end this sheet moves. @@ -170,9 +184,13 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { onClose={shell.closeSheet} variant="bottom" material="opaque" - ariaLabel={t('roadtrip.stay.add')} + // Named for what it holds: a stop left at a set time has no stay to add. + ariaLabel={leave.until ? t('roadtrip.stop.stay') : t('roadtrip.stay.add')} > - {stop && ( + {stop && leave.until && ( + + )} + {stop && !leave.until && ( <> ) } + +/** + * The sheet for a stop the traveller leaves at a set time, where there is no stay to pick. + * + * It says when the drive leaves and what stay that makes, and offers the one thing there + * is to do: take the end time off the visit, after which the stay is a choice again. + */ +function RtLeaveTime({ name, leave, arrival, departure, onClose }: { + name?: string + leave: LeaveMode + arrival: string | null + departure: string | null + onClose: () => void +}) { + const { t, locale } = useTranslation() + const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const until = displayTime(leave.until, locale, timeFormat) + const captionCls = 'font-geist text-[0.5625rem] font-bold uppercase tracking-[.09em] text-m-faint' + return ( + <> + +
+ {name &&
{name}
} +
+ {leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)} +
+ {/* What the drive actually does, as the desktop dialog says it: reached too late it + leaves on arrival, with the travel hours over first it goes on in the morning. */} + {leave.missedBy !== null ? ( +
+
+ ) : ( +
+ {t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })} +
+ )} + {arrival && !leave.dayEndsFirst && ( +
+ + {t('roadtrip.stay.arrive')} + + {displayTime(arrival, locale, timeFormat)} + + +
+ )} +
+ {leave.remove && ( +
+ +
+ )} + + ) +} diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx index 7776813ea..8d1963828 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx @@ -13,6 +13,7 @@ import { useTripStore } from '../../../../store/tripStore' import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' +import { readStay, shownStay } from '../../../../components/Roadtrip/stayReading' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation' import { NavigationMenu } from '../../../../components/shared/NavigationMenu' @@ -39,10 +40,11 @@ import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip' * * Editing the place is the one way out of that rule, and it is a hand-over rather than a * control of this sheet: the pencil opens the place editor the plan tab opens, with this - * very visit in context, and this sheet closes behind it. The time somebody fixed for a - * stop is that editor's start time (the chain anchors on the visit's own start time), so - * a time control here would be a second writer for one number. A booked night can also - * be held by its check-in, which belongs to the booking and not to the place. + * very visit in context, and this sheet closes behind it. The times somebody fixed for a + * stop are that editor's Start and End (the chain arrives at the visit's own Start and + * leaves at its End), so a time control here would be a second writer for one number. A + * booked night can also be held by its check-in, which belongs to the booking and not to + * the place. */ interface RtStopSheetPayload { @@ -203,16 +205,17 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { // Every finding filed at this stop, not just the one the chain had room for: being late // and driving too far are separate answers and the sheet is where both fit. const warnings: ScheduleWarning[] = [ - ...day.schedule.warnings.filter(w => w.index === index && w.code === 'late'), + ...day.schedule.warnings.filter(w => w.index === index && (w.code === 'late' || w.code === 'missedLeave')), ...day.driveWarnings.filter(w => w.index === index && (w.code === 'leg' || w.code === 'range')), ] const warningText = (w: ScheduleWarning): string => { if (w.code === 'late') return t('roadtrip.warn.late', { minutes: w.minutes ?? 0 }) + if (w.code === 'missedLeave') return t('roadtrip.warn.missedLeave', { minutes: w.minutes ?? 0 }) if (w.code === 'range') return t('roadtrip.limit.range', { distance: formatDistance(w.sinceKm ?? 0, unit) }) return t('roadtrip.limit.legOver', { time: formatDurationShort((w.overMinutes ?? 0) * 60) }) } const warningIcon = (w: ScheduleWarning): ReactNode => { - if (w.code === 'late') return + if (w.code === 'late' || w.code === 'missedLeave') return if (w.code === 'range') return return } @@ -226,7 +229,13 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { else if (navTargets.length > 1) setNavOpen(true) } - const stayMinutes = stop.dwellMinutes + // A stop left at a set time is stood at until then: the stay is what that time leaves + // of it, and the sheet says until when. + const stay = readStay(stop, entry) + const missed = warnings.some(w => w.code === 'missedLeave') + const shown = shownStay(stay) + const until = stay.until ? t('roadtrip.stay.until', { time: displayTime(stay.until, locale, timeFormat) }) : null + const stayText = [shown === null ? null : formatDurationShort(shown * 60), until].filter(Boolean).join(' ') const canEditPlace = planner.can('place_edit', planner.trip) const editStay = () => { // The row this sheet is on rides along, because the stay sheet comes back here when it @@ -235,7 +244,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { // that is not on screen, which is what the day swipe reads to decide it is blocked. shell.openSheet('rtstay', { placeId: stop.placeId, - minutes: stayMinutes, + minutes: stop.dwellMinutes, name: stop.name, dayId: located.day.dayId, assignmentId: stop.assignmentId, @@ -353,17 +362,27 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
{entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
+ {/* Only where it is true. Reached too late the stop is left on arrival, which + the finding below says; with the travel hours over first the drive goes on + in the morning, not at the time or at the Leave shown above. */} + {stay.until && !missed && ( +
+ {t(stay.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { + time: displayTime(stay.until, locale, timeFormat), + })} +
+ )}
)} {/* ── What this stop costs, and what the drive to it went past ── */} - {(!!stayMinutes || !!row.offRoadMeters || warnings.length > 0) && ( + {(!!stayText || !!row.offRoadMeters || warnings.length > 0) && (
- {!!stayMinutes && ( + {!!stayText && ( } label={t('roadtrip.stop.stayShort')} - value={formatDurationShort(stayMinutes * 60)} + value={stayText} /> )} {!!row.offRoadMeters && ( @@ -399,7 +418,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) { } label={t('roadtrip.stop.stayShort')} - value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')} + value={stayText || t('roadtrip.stay.none')} onClick={canEditPlace ? editStay : undefined} />
diff --git a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx index 3da0ba31c..5f50179a7 100644 --- a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx +++ b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx @@ -29,6 +29,9 @@ export default function PlTimeFields({ planner, startTime, endTime, onChange, assignmentId, dayAssignments, hasTimeError, }: PlTimeFieldsProps) { const { t } = planner + // Opened from the road trip tab, where the End of a visit is when the drive leaves it. + // The plan tab reads the day as Days does, and there End stays a plain label. + const endIsLeave = planner.activeTab === 'roadtrip' const collisions = useMemo(() => { if (!startTime || startTime.length < 5) return [] @@ -58,6 +61,9 @@ export default function PlTimeFields({
{t('places.endTime')} onChange('end_time', v)} /> + {endIsLeave && ( +
{t('roadtrip.stop.endIsLeave')}
+ )}
{hasTimeError && ( diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx new file mode 100644 index 000000000..869e65ce6 --- /dev/null +++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx @@ -0,0 +1,400 @@ +// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-018 +import type { ComponentProps } from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render' +import { docsyncApi } from '../../../../api/client' +import type { DocSyncConnection, DocSyncLink, DocSyncProvider, DocSyncScope } from '../../../../components/Files/docsync/useDocSync' +import MDocSyncSheet from './MDocSyncSheet' + +/** + * The phone sheet is markup over `useDocSync`, so everything here goes through + * the one module boundary that hook has: `docsyncApi`. Nothing reaches into the + * component. + */ + +const TRIP_ID = 3 + +/** + * Shaped like a row of document_provider_fields, which matters more than it looks. + * + * `field_key` is snake_case and `label` already carries the full key suffix + * (`providerApiKey`, not `apiKey`), see server/src/db/document-provider-seed.ts. + * An earlier version of this fixture invented both, which made a sheet that + * built label keys the wrong way look correct: the fixture's `apiKey` and the + * sheet's `docsync.provider${cap(...)}` happened to meet in the middle, while + * the real data produced `docsync.providerProviderApiKey` and rendered the raw + * key on screen. + */ +const field = (over: Partial): DocSyncProvider['fields'][number] => ({ + field_key: 'api_token', + label: 'providerApiToken', + input_type: 'text', + placeholder: null, + hint: null, + required: true, + secret: true, + ...over, +}) + +const provider = (over: Partial): DocSyncProvider => ({ + id: 'paperless', + name: 'Paperless', + description: null, + icon: 'paperless', + available: true, + fields: [ + field({ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example', secret: false }), + field({}), + ], + ...over, +}) + +const connection = (over: Partial): DocSyncConnection => ({ + id: 5, + providerId: 'paperless', + baseUrl: 'https://paperless.example', + settings: {}, + secrets: {}, + allowInsecureTls: false, + lastProbeState: 'ok', + lastProbeError: null, + ...over, +}) + +const link = (over: Partial = {}): DocSyncLink => ({ + id: 11, + connectionId: 5, + providerId: 'paperless', + scopeKey: 'tag:trek-3', + remoteLabel: 'TREK trip 3', + remoteRootPath: null, + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + webhookUrl: null, + ...over, +}) + +const scope = (over: Partial): DocSyncScope => ({ + scopeKey: 'folder:1', + label: 'Photos', + remoteRootId: '1', + remoteRootPath: '/Photos', + ...over, +}) + +/** Server state the spies read, so a mutation can change what the next load returns. */ +let providers: DocSyncProvider[] +let connections: DocSyncConnection[] +let links: DocSyncLink[] +let scopes: DocSyncScope[] + +const onClose = vi.fn() + +function renderSheet(over: Partial> = {}) { + return render( + , + ) +} + +/** List → detail for the one bound store. */ +async function openDetail() { + fireEvent.click(await screen.findByRole('button', { name: /Paperless/ })) + await screen.findByRole('button', { name: 'Sync now' }) +} + +const lane = (name: RegExp) => screen.getByRole('button', { name }) + +beforeEach(() => { + onClose.mockClear() + providers = [ + provider({}), + provider({ id: 'nextcloud', name: 'Nextcloud', fields: [field({ field_key: 'app_password', label: 'providerAppPassword' })] }), + provider({ id: 'papra', name: 'Papra', fields: [field({ field_key: 'api_key', label: 'providerApiKey' })] }), + ] + connections = [connection({}), connection({ id: 6, providerId: 'nextcloud' })] + links = [link()] + scopes = [scope({}), scope({ scopeKey: 'folder:2', label: 'Invoices', remoteRootId: '2', remoteRootPath: '/Invoices' })] + + vi.spyOn(docsyncApi, 'providers').mockImplementation(async () => providers) + vi.spyOn(docsyncApi, 'listConnections').mockImplementation(async () => connections) + vi.spyOn(docsyncApi, 'listLinks').mockImplementation(async () => links.map(l => ({ ...l }))) + vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({ + items: {}, + links: links.map(l => ({ id: l.id, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })), + })) + vi.spyOn(docsyncApi, 'listScopes').mockImplementation(async () => ({ scopes })) + vi.spyOn(docsyncApi, 'updateLink').mockResolvedValue({}) + vi.spyOn(docsyncApi, 'deleteLink').mockImplementation(async () => { links = []; return {} }) + vi.spyOn(docsyncApi, 'syncNow').mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) + vi.spyOn(docsyncApi, 'testConnection').mockResolvedValue({ connected: true }) + vi.spyOn(docsyncApi, 'saveConnection').mockResolvedValue({}) + vi.spyOn(docsyncApi, 'createScope').mockResolvedValue(scope({})) + vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('MDocSyncSheet: opening and the store list', () => { + it('FE-DOCSYNC-MOBILE-001: renders nothing and asks the server nothing while it is closed', () => { + renderSheet({ open: false }) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(docsyncApi.providers).not.toHaveBeenCalled() + expect(docsyncApi.listLinks).not.toHaveBeenCalled() + }) + + it('FE-DOCSYNC-MOBILE-002: shows a spinner until the first load lands', async () => { + let arrive!: (v: DocSyncProvider[]) => void + vi.spyOn(docsyncApi, 'providers').mockReturnValue(new Promise(r => { arrive = r })) + + renderSheet() + + expect(document.querySelector('.animate-spin')).not.toBeNull() + expect(screen.queryByRole('button', { name: /Paperless/ })).not.toBeInTheDocument() + + arrive(providers) + expect(await screen.findByRole('button', { name: /Paperless/ })).toBeInTheDocument() + expect(document.querySelector('.animate-spin')).toBeNull() + }) + + it('FE-DOCSYNC-MOBILE-003: lists the bound store and, separately, the ones that could be added', async () => { + renderSheet() + + const bound = await screen.findByRole('button', { name: /Paperless/ }) + expect(bound).toHaveAccessibleName(expect.stringContaining('TREK trip 3')) + expect(screen.getByText('This trip')).toBeInTheDocument() + expect(screen.getByText('Add another')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Papra/ })).toBeInTheDocument() + // The trip's own name rides along under the sheet title. + expect(screen.getByRole('dialog', { name: 'Document sync' })).toBeInTheDocument() + expect(screen.getByText('Rome')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-004: an instance with no providers says so instead of listing nothing', async () => { + providers = [] + links = [] + renderSheet() + + expect(await screen.findByText('No document providers are available')).toBeInTheDocument() + expect(screen.queryByText('This trip')).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-017: a bound trip keeps showing its store after every provider was switched off', async () => { + providers = [] + links = [link({ providerName: 'Paperless-ngx' })] + renderSheet({ canManage: false }) + + const row = await screen.findByRole('button', { name: /TREK trip 3/ }) + expect(screen.queryByText('No document providers are available')).not.toBeInTheDocument() + expect(screen.queryByText('Add another')).not.toBeInTheDocument() + // The providers route no longer names it, so the name comes off the link, + // in the list and again as the detail view's title and far end. + expect(row).toHaveAccessibleName(expect.stringContaining('Paperless-ngx')) + + fireEvent.click(row) + await screen.findByRole('button', { name: 'Sync now' }) + expect(screen.getAllByText('Paperless-ngx')).toHaveLength(2) + expect(screen.queryByText('Document sync')).not.toBeInTheDocument() + expect(screen.queryByText('paperless')).not.toBeInTheDocument() + }) +}) + +describe('MDocSyncSheet: moving between the views', () => { + it('FE-DOCSYNC-MOBILE-005: tapping a bound store opens its detail view', async () => { + renderSheet() + + fireEvent.click(await screen.findByRole('button', { name: /Paperless/ })) + + expect(await screen.findByRole('button', { name: 'Sync now' })).toBeInTheDocument() + expect(screen.getByText('Folder')).toBeInTheDocument() + expect(screen.getByText('TREK trip 3')).toBeInTheDocument() + expect(screen.getByText('not run yet')).toBeInTheDocument() + // The list is gone, not merely scrolled past. + expect(screen.queryByText('Add another')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-006: the back arrow returns to the list, and the list only has a back arrow once you have left it', async () => { + renderSheet() + await screen.findByRole('button', { name: /Paperless/ }) + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + + await openDetail() + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + + expect(screen.getByText('Add another')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-007: reopening the sheet starts at the list again, not where it was left', async () => { + const { rerender } = renderSheet() + await openDetail() + + rerender() + rerender() + + expect(await screen.findByText('Add another')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-008: an addable store with no connection asks for credentials first', async () => { + renderSheet() + + fireEvent.click(await screen.findByRole('button', { name: /Papra/ })) + + expect(await screen.findByRole('button', { name: 'Test connection' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + expect(screen.getByText('API key')).toBeInTheDocument() + expect(docsyncApi.listScopes).not.toHaveBeenCalled() + // The header now names the store being connected. + expect(screen.getByRole('dialog', { name: 'Document sync' })).toBeInTheDocument() + expect(screen.getByText('Papra')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-009: an addable store that already has a connection goes straight to the folder view', async () => { + renderSheet() + + fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ })) + + expect(await screen.findByRole('button', { name: /Invoices/ })).toBeInTheDocument() + expect(docsyncApi.listScopes).toHaveBeenCalledWith(TRIP_ID, 6, undefined) + expect(screen.queryByRole('button', { name: 'Test connection' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Photos/ })).toBeInTheDocument() + // A folder name is suggested from the trip so nobody has to invent one. + expect(screen.getByDisplayValue('rome-3')).toBeInTheDocument() + }) +}) + +describe('MDocSyncSheet: the two lanes', () => { + it('FE-DOCSYNC-MOBILE-010: switching a lane off narrows the direction to the other one', async () => { + renderSheet() + await openDetail() + + expect(lane(/Out to the store/)).toHaveAttribute('aria-pressed', 'true') + expect(lane(/In from the store/)).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(lane(/Out to the store/)) + + await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { direction: 'pull' })) + }) + + it('FE-DOCSYNC-MOBILE-011: the last remaining lane cannot be switched off, and the dead one carries the hatch', async () => { + links = [link({ direction: 'push' })] + renderSheet() + await openDetail() + + const out = lane(/Out to the store/) + const back = lane(/In from the store/) + expect(out).toHaveAttribute('aria-pressed', 'true') + expect(back).toHaveAttribute('aria-pressed', 'false') + expect(back.className).toContain('trek-docsync-lane-off-m') + expect(out.className).not.toContain('trek-docsync-lane-off-m') + + fireEvent.click(out) + + expect(docsyncApi.updateLink).not.toHaveBeenCalled() + expect(lane(/Out to the store/)).toHaveAttribute('aria-pressed', 'true') + + // The off lane can still be switched back on. + fireEvent.click(back) + await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { direction: 'both' })) + }) +}) + +describe('MDocSyncSheet: what the owner may do', () => { + it('FE-DOCSYNC-MOBILE-012: the auto-sync switch patches the binding', async () => { + renderSheet() + await openDetail() + + const auto = screen.getByRole('switch', { name: 'Sync automatically' }) + expect(auto).toBeChecked() + + fireEvent.click(auto) + + await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { syncEnabled: false })) + }) + + it('FE-DOCSYNC-MOBILE-013: disconnecting asks first, then unbinds and drops back to the list', async () => { + renderSheet() + await openDetail() + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + const confirm = screen.getByRole('dialog', { name: 'Disconnect' }) + expect(docsyncApi.deleteLink).not.toHaveBeenCalled() + + fireEvent.click(within(confirm).getByRole('button', { name: 'Disconnect' })) + + await waitFor(() => expect(docsyncApi.deleteLink).toHaveBeenCalledWith(TRIP_ID, 11)) + expect(await screen.findByText('Connect a provider')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-018: a binding whose provider an admin switched off says so, and a refused run leaves it at that', async () => { + let off = false + vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({ + items: {}, + links: links.map(l => ({ id: l.id, providerOff: off, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })), + })) + vi.spyOn(docsyncApi, 'syncNow').mockImplementation(async () => { + off = true + throw { response: { status: 409, data: { error: 'provider_disabled' } } } + }) + renderSheet() + await openDetail() + expect(screen.queryByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Sync now' })) + + expect(await screen.findByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-014: Sync now runs that binding', async () => { + renderSheet() + await openDetail() + + fireEvent.click(screen.getByRole('button', { name: 'Sync now' })) + + await waitFor(() => expect(docsyncApi.syncNow).toHaveBeenCalledWith(TRIP_ID, 11, false)) + }) +}) + +describe('MDocSyncSheet: what a member may do', () => { + it('FE-DOCSYNC-MOBILE-015: a member sees no settings and no disconnect, and the lanes are inert', async () => { + renderSheet({ canManage: false }) + await openDetail() + + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument() + expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument() + + expect(lane(/Out to the store/)).toBeDisabled() + expect(lane(/In from the store/)).toBeDisabled() + fireEvent.click(lane(/Out to the store/)) + expect(docsyncApi.updateLink).not.toHaveBeenCalled() + + // Reading and running it is still theirs. + expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled() + }) + + it('FE-DOCSYNC-MOBILE-016: a member is not offered stores to add, and an unbound trip explains who sets it up', async () => { + links = [] + renderSheet({ canManage: false }) + + expect(await screen.findByText('Nothing connected yet')).toBeInTheDocument() + expect(screen.getByText('The trip owner sets this up. Documents stay in TREK either way.')).toBeInTheDocument() + expect(screen.queryByText('Connect a provider')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument() + }) +}) diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx new file mode 100644 index 000000000..a3bc14bbf --- /dev/null +++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx @@ -0,0 +1,825 @@ +import { useEffect, useMemo, useState } from 'react' +import { + ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, Link2Off, + Loader2, Plus, RefreshCw, +} from 'lucide-react' +import MSheet from '../../../components/MSheet' +import MIconBtn from '../../../components/MIconBtn' +import MToggle from '../../../components/MToggle' +import MConfirmSheet from '../../settings/MConfirmSheet' +import { TileHeader } from '../sheets/MTripSheetUi' +import { useTranslation } from '../../../../i18n' +import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons' +import TrekIcon from '../../../../components/shared/TrekIcon' +import { + bindingNotice, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, +} from '../../../../components/Files/docsync/useDocSync' +import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm' +import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits' +import { useConflicts } from '../../../../components/Files/docsync/useConflicts' +import { relativeTime } from '../../../../utils/relativeTime' + +/** + * Document sync on the phone. + * + * Everything that decides anything lives in `useDocSync`, which the desktop + * panel uses unchanged. This file is markup in the phone's design language and + * nothing else. The two shells are a deliberate mirror, and duplicated logic + * across them is what the 3% duplication budget is spent on. + * + * One sheet with four views rather than four stacked sheets: the flow is + * list → credentials → folder → detail, and a phone that opens a sheet per step + * buries the close button three layers deep. + */ +type View = 'list' | 'detail' | 'connect' | 'scope' + +export default function MDocSyncSheet({ + tripId, + tripTitle, + canManage, + open, + onClose, +}: { + tripId: number | string + tripTitle?: string + canManage: boolean + open: boolean + onClose: () => void +}) { + const { t } = useTranslation() + const sync = useDocSync(tripId, open) + const [view, setView] = useState('list') + const [selected, setSelected] = useState(null) + const [pending, setPending] = useState(null) + const [confirmUnlink, setConfirmUnlink] = useState(false) + + const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links]) + const available = sync.providers.filter(p => !bound.has(p.id)) + const link = sync.links.find(l => l.id === selected) ?? null + + // A store that was removed elsewhere must not leave the detail view pointing + // at nothing. + useEffect(() => { + if (view === 'detail' && !link) setView('list') + }, [view, link]) + + // Every open starts at the list: a sheet that reopens three steps deep is a + // sheet somebody has to find their way out of. + useEffect(() => { + if (!open) { setView('list'); setPending(null) } + }, [open]) + + const openStore = (p: DocSyncProvider) => { + setPending(p) + setView(sync.connectionFor(p.id) ? 'scope' : 'connect') + } + + const title = + view === 'connect' || view === 'scope' + ? pending?.name ?? t('docsync.title') + : view === 'detail' && link + ? storeName(link, sync.providers) + : t('docsync.title') + + return ( + <> + +
+
+ {view !== 'list' && ( + setView('list')} ariaLabel={t('common.back')}> + + + )} +
+ } + title={title} + sub={view === 'list' ? tripTitle : undefined} + onClose={onClose} + closeLabel={t('common.close')} + /> +
+
+
+ +
+ {sync.loading ? ( +
+ +
+ ) : sync.providers.length === 0 && sync.links.length === 0 ? ( + // Only with nothing bound, as on the desktop panel. + + ) : view === 'connect' && pending ? ( + setView('scope')} + /> + ) : view === 'scope' && pending && sync.connectionFor(pending.id) ? ( + setView('list')} + /> + ) : view === 'detail' && link ? ( + setConfirmUnlink(true)} + /> + ) : ( + { setSelected(id); setView('detail') }} + onAdd={openStore} + /> + )} +
+
+ + setConfirmUnlink(false)} + title={t('docsync.unlink')} + message={t('docsync.confirmUnlink')} + confirmLabel={t('docsync.unlink')} + cancelLabel={t('common.cancel')} + danger + onConfirm={() => { + setConfirmUnlink(false) + if (link) void sync.removeLink(link.id).then(() => setView('list')) + }} + /> + + ) +} + +/** The stores this trip uses, then the ones it could. */ +function ListView({ + links, + providers, + available, + canManage, + onOpen, + onAdd, +}: { + links: DocSyncLink[] + providers: DocSyncProvider[] + available: DocSyncProvider[] + canManage: boolean + onOpen: (id: number) => void + onAdd: (p: DocSyncProvider) => void +}) { + const { t } = useTranslation() + return ( +
+ {links.length > 0 && ( +
+ {t('docsync.sidebar.connected')} +
+ {links.map(l => ( + onOpen(l.id)} + /> + ))} +
+
+ )} + + {canManage && available.length > 0 && ( +
+ {links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')} +
+ {available.map(p => ( + onAdd(p)} + /> + ))} +
+
+ )} + + {links.length === 0 && !canManage && ( + + )} +
+ ) +} + +/** One binding: the flow, a run button, its settings. */ +function DetailView({ + link, + tripId, + providerName, + sync, + canManage, + onUnlink, +}: { + link: DocSyncLink + tripId: number | string + providerName: string + sync: ReturnType + canManage: boolean + onUnlink: () => void +}) { + const { t, language } = useTranslation() + const busy = sync.busy === `sync-${link.id}` + const holdings = link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 } + const pushOn = link.direction === 'both' || link.direction === 'push' + const pullOn = link.direction === 'both' || link.direction === 'pull' + const ProviderIcon = DOCUMENT_PROVIDER_ICONS[link.providerId] + + /** The last remaining direction cannot be switched off. */ + const toggle = (lane: 'push' | 'pull') => { + if (!canManage) return + const nextPush = lane === 'push' ? !pushOn : pushOn + const nextPull = lane === 'pull' ? !pullOn : pullOn + if (!nextPush && !nextPull) return + void sync.updateLink(link.id, { direction: nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull' }) + } + + const ranAt = link.lastSyncAt ? Date.parse(link.lastSyncAt) : NaN + const notice = bindingNotice(link, t) + + return ( +
+ {/* The two ends, side by side, with the lanes stacked under them: the + desktop bar puts them left and right of each other, which needs width + a phone does not have. */} +
+
+ } count={holdings.inTrek} name={t('docsync.flow.trek')} /> + : null} + count={holdings.atProvider} + name={providerName} + /> +
+ +
+ } + disabled={!canManage} + onClick={() => toggle('push')} + /> + } + disabled={!canManage} + onClick={() => toggle('pull')} + /> +
+
+ + + + + {notice && ( +

+ {notice} +

+ )} + + + + + + {canManage && ( + <> + + void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })} + /> + + + + + + + + + + + + + )} +
+ ) +} + +/** + * Credentials for a store this instance offers but the trip has not used yet. + * + * The rules (which field is the address, which one is a switch, how a label + * becomes a key) live in `useConnectForm`, the same one the desktop dialog + * uses. Written out here a second time they came out wrong in four separate + * ways, including an address this form never actually sent. + */ +function ConnectView({ + provider, + sync, + onDone, +}: { + provider: DocSyncProvider + sync: ReturnType + onDone: () => void +}) { + const { t } = useTranslation() + const form = useConnectForm(provider, sync.connectionFor(provider.id), sync) + const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) + + return ( +
+

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

+ + {form.fields.map(f => ( + + ))} + +
+ + {t('docsync.allowInsecureTls')} + + {t('docsync.connect.insecureHint')} + + + +
+ + {verdict && ( +

+ {verdict.connected + ? verdict.account + ? t('docsync.connect.okAs', { account: verdict.account }) + : t('docsync.connected') + : t(`docsync.error.${verdict.error ?? 'unknown'}`)} +

+ )} + + {sync.error && !verdict && ( +

+ {t(`docsync.error.${sync.error}`)} +

+ )} + +
+ + +
+
+ ) +} + +/** Which folder, tag or space this trip gets. */ +function ScopeView({ + connectionId, + suggestedName, + sync, + onBound, +}: { + connectionId: number + suggestedName: string + sync: ReturnType + onBound: () => void +}) { + const { t } = useTranslation() + const [scopes, setScopes] = useState>['scopes'] | null>(null) + const [error, setError] = useState(null) + const [name, setName] = useState(suggestedName) + const [working, setWorking] = useState(null) + + // `loadScopes`, not `sync`: the hook hands back a fresh object on every + // render of the panel above, so depending on it re-listed the provider's + // folders each time anything up there changed. The callback itself is + // stable. Taken out of `sync` first, because calling it as `sync.loadScopes` + // inside the effect makes the whole object a dependency again. + const { loadScopes } = sync + useEffect(() => { + let cancelled = false + void (async () => { + const res = await loadScopes(connectionId) + if (cancelled) return + setScopes(res.scopes) + setError(res.error ?? null) + })() + return () => { cancelled = true } + }, [connectionId, loadScopes]) + + const bind = async (scope: { scopeKey: string; label: string; remoteRootId: string | null; remoteRootPath: string | null }) => { + setWorking(scope.scopeKey) + const ok = await sync.createLink({ + connectionId, + scopeKey: scope.scopeKey, + remoteRootId: scope.remoteRootId, + remoteRootPath: scope.remoteRootPath, + remoteLabel: scope.label, + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + }) + setWorking(null) + if (ok) onBound() + } + + return ( +
+

{t('docsync.scope.intro')}

+ +
+ setName(e.target.value)} + placeholder={t('docsync.newFolderPlaceholder')} + className="h-11 min-w-0 flex-1 rounded-2xl border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-3 text-[0.8125rem] text-m-ink outline-none" + /> + +
+ + {sync.error && ( +

+ {t(`docsync.error.${sync.error}`)} +

+ )} + + {t('docsync.scope.pickTitle')} + + {scopes === null ? ( +
+ ) : error ? ( +

+ {t(`docsync.error.${error}`)} +

+ ) : scopes.length === 0 ? ( + + ) : ( +
+ {scopes.map(s => ( + + ))} +
+ )} +
+ ) +} + +/** One store in the list, connected or not. */ +function StoreRow({ + providerId, + title, + sub, + state, + addable, + onClick, +}: { + providerId: string + title: string + sub: string + state?: string + addable?: boolean + onClick: () => void +}) { + const Icon = DOCUMENT_PROVIDER_ICONS[providerId] + return ( + + ) +} + +/** One side of the flow: how much it is holding. */ +function EndBox({ glyph, count, name }: { glyph: React.ReactNode; count: number; name: string }) { + const { t } = useTranslation() + return ( +
+ + {glyph} + + {count} + + {t('docsync.flow.documents')} + + {name} +
+ ) +} + +/** + * One direction, on or off. + * + * The off state is the desktop's hatch, scaled for a phone: the same reading of + * "nothing goes through here" without a second vocabulary to learn. + */ +function LaneRow({ + active, + label, + icon, + disabled, + onClick, +}: { + active: boolean + label: string + icon: React.ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} + +function StateDot({ state }: { state: string }) { + const { t } = useTranslation() + const tone = + state === 'ok' ? 'var(--m-st-ok, #22c55e)' + : state === 'partial' ? 'var(--m-st-warn, #f59e0b)' + : state === 'never' ? 'var(--m-trackoff)' + : 'var(--m-st-danger)' + return ( + + ) +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {label} + {hint && {hint}} + + {children} +
+ ) +} + +function Empty({ text, hint }: { text: string; hint?: string }) { + return ( +
+
{text}
+ {hint &&
{hint}
} +
+ ) +} + +/** A folder name from the trip's own title, so nobody has to invent one. */ +function slugFor(title: string | undefined, tripId: number | string): string { + const base = (title || 'trek') + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + return `${base || 'trek'}-${tripId}` +} + +/** + * The documents that changed in both places, with the way out. + * + * The same three answers the panel offers, through the same hook. Only the + * markup is a phone's. + */ +function MConflicts({ + tripId, + sync, + count, +}: { + tripId: number | string + sync: ReturnType + count: number +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const conflicts = useConflicts(tripId, sync, open) + if (count === 0) return null + + return ( +
+ + + {open && ( +
    + {conflicts.items === null && ( +
  • + +
  • + )} + {conflicts.items?.map(item => ( +
  • +

    {item.name}

    +
    + {(['trek', 'provider', 'both'] as const).map(keep => ( + + ))} +
    +
  • + ))} +
+ )} +
+ ) +} diff --git a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx index 154490f83..6d79fe723 100644 --- a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx @@ -11,6 +11,9 @@ import type { MTabScreenProps } from './tabModel' import MFileMenuSheet from './MFileMenuSheet' import MFileLinkSheet from './MFileLinkSheet' import MFileTrashSheet from './MFileTrashSheet' +import MDocSyncSheet from './MDocSyncSheet' +import { canManageDocSync } from '../../../../components/Files/docsync/useDocSync' +import { useAuthStore } from '../../../../store/authStore' import MFileLightbox from './MFileLightbox' import { FILE_FILTERS, buildFileLinkLabels, formatFileDate, getFileTypeMeta, @@ -36,7 +39,9 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) { const [filter, setFilter] = useState('all') const [menuFileId, setMenuFileId] = useState(null) const [linkFileId, setLinkFileId] = useState(null) + const currentUser = useAuthStore(st => st.user) const [trashOpen, setTrashOpen] = useState(false) + const [docSyncOpen, setDocSyncOpen] = useState(false) const [lightboxIndex, setLightboxIndex] = useState(null) const [uploading, setUploading] = useState(false) @@ -110,6 +115,15 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) { lastTrashSignal.current = shell.openFilesTrashSignal }, [shell.openFilesTrashSignal]) + // ── Document sync: same signal pattern again. ── + const lastDocSyncSignal = useRef(shell.openDocSyncSignal) + useEffect(() => { + if (shell.openDocSyncSignal !== lastDocSyncSignal.current && shell.openDocSyncSignal > 0) { + setDocSyncOpen(true) + } + lastDocSyncSignal.current = shell.openDocSyncSignal + }, [shell.openDocSyncSignal]) + // ── Star toggle (ungated, §7.6) — direct filesApi call + store refresh, same as §7.3. ── const toggleStar = async (file: TripFile) => { try { @@ -211,6 +225,13 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) { /> setLinkFileId(null)} /> setTrashOpen(false)} /> + setDocSyncOpen(false)} + /> {lightboxIndex != null && mediaFiles.length > 0 && ( )} - { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} /> setShowTripForm(false)} diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 9644cec18..b06938313 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -213,7 +213,7 @@ async function renderRoadtrip() { rt.routes.days = [{ ...rt.corridor.day, stops }] } rt.routes.days = rt.routes.days.map(day => ({ - schedule: { entries: [] }, + schedule: { entries: [], warnings: [] }, legs: [], ...day, stops: (day.stops as Array>).map((stop, i) => ({ @@ -1972,7 +1972,7 @@ describe('useTripPlanner road trip: the phone feed', () => { rt.routes.days = [{ dayId, dayNumber: 1, - schedule: { entries: [] }, + schedule: { entries: [], warnings: [] }, legs: [], stops: [0, 1, 2].map((i) => ({ assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 8d2d9bf2d..ad72b11db 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -52,6 +52,8 @@ import { isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal' +import { inspectorStay } from '../../components/Roadtrip/stayReading' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { usePlannerHistory } from '../../hooks/usePlannerHistory' @@ -1229,7 +1231,7 @@ export function useTripPlanner() { * the rail is a list — putting a dialog's state inside a row means it dies whenever the * list re-renders around it. */ - const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null) + const [stayDraft, setStayDraft] = useState(null) /** * Whether the day ends at this stop, from BOTH the things that can end it. @@ -2437,7 +2439,7 @@ export function useTripPlanner() { ? { active: roadtripEndsDayAt(endDayStop), onToggle: () => setRoadtripEndDay(endDayStop) } : undefined const roadtripStay = roadtripActive && selectedPlace - ? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined } + ? inspectorStay(roadtripRoutes.days, endDayStop, selectedPlace, can('place_edit', trip) ? editRoadtripStay : undefined) : undefined // Build placeId → order-number map from the selected day's assignments diff --git a/client/src/repo/assignmentRepo.test.ts b/client/src/repo/assignmentRepo.test.ts index 3187c8f65..a78916a9e 100644 --- a/client/src/repo/assignmentRepo.test.ts +++ b/client/src/repo/assignmentRepo.test.ts @@ -5,13 +5,13 @@ import { offlineDb, clearAll } from '../db/offlineDb' import { saveAssignmentEndDay } from '../api/assignmentEndDay' import { isEffectivelyOffline } from '../sync/networkMode' import { mutationQueue } from '../sync/mutationQueue' -import { apiClient } from '../api/client' +import { apiClient, assignmentsApi } from '../api/client' import type { Assignment, Day } from '../types' vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() })) vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) })) vi.mock('../sync/authGate', () => ({ isAuthed: () => true })) -vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() } })) +vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() }, assignmentsApi: { updateTime: vi.fn() } })) const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment beforeEach(async () => { @@ -50,3 +50,32 @@ describe('assignment day-end persistence', () => { expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined() }) }) + +describe('assignment time persistence', () => { + const times = { place_time: '07:00', end_time: null } + + it('stores the offline times with a replayable write of both of them', async () => { + await assignmentRepo.setTimes(9, { ...assignment, assignment_end_time: '14:00' }, times) + expect((await offlineDb.days.get(1))?.assignments?.[0]).toMatchObject({ assignment_time: '07:00', assignment_end_time: null }) + expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({ + url: '/trips/9/assignments/7/time', method: 'PUT', body: times, resource: 'assignments', entityId: 7, + })]) + }) + + it('saves through the time route online and caches what came back', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + vi.mocked(assignmentsApi.updateTime).mockResolvedValue({ assignment: { ...assignment, assignment_end_time: null } }) + const saved = await assignmentRepo.setTimes(9, { ...assignment, assignment_end_time: '14:00' }, times) + expect(assignmentsApi.updateTime).toHaveBeenCalledWith(9, 7, times) + expect(saved.assignment_end_time).toBeNull() + expect((await offlineDb.days.get(1))?.assignments?.[0].assignment_end_time).toBeNull() + expect(await offlineDb.mutationQueue.count()).toBe(0) + }) + + it('keeps the cached visit unchanged after a refused save', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(false) + vi.mocked(assignmentsApi.updateTime).mockRejectedValue(new Error('Denied')) + await expect(assignmentRepo.setTimes(9, assignment, times)).rejects.toThrow('Denied') + expect((await offlineDb.days.get(1))?.assignments?.[0]).toEqual(assignment) + }) +}) diff --git a/client/src/repo/assignmentRepo.ts b/client/src/repo/assignmentRepo.ts index 95b3e0645..eee44f54a 100644 --- a/client/src/repo/assignmentRepo.ts +++ b/client/src/repo/assignmentRepo.ts @@ -1,10 +1,18 @@ +import { assignmentSchema } from '@trek/shared' import { saveAssignmentEndDay } from '../api/assignmentEndDay' +import { assignmentsApi } from '../api/client' import { offlineDb } from '../db/offlineDb' import { cacheAssignment } from '../db/cacheAssignment' import { generateUUID, mutationQueue } from '../sync/mutationQueue' import { isEffectivelyOffline } from '../sync/networkMode' import type { Assignment } from '../types' +/** Start and End of one visit, as the time route stores them: null is no time. */ +export interface AssignmentTimes { + place_time: string | null + end_time: string | null +} + export const assignmentRepo = { async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise { if (!isEffectivelyOffline()) { @@ -23,4 +31,28 @@ export const assignmentRepo = { }) return updated }, + + /** + * A visit's own Start and End, the pair the time route takes. + * + * The route writes both columns every time, so a caller changing one of them hands the + * other in as it stands; leaving it out would clear it. + */ + async setTimes(tripId: number | string, assignment: Assignment, times: AssignmentTimes): Promise { + if (!isEffectivelyOffline()) { + const saved = assignmentSchema.parse((await assignmentsApi.updateTime(tripId, assignment.id, times)).assignment) + await cacheAssignment(saved) + return saved + } + const updated = { ...assignment, assignment_time: times.place_time, assignment_end_time: times.end_time } + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'PUT', + url: `/trips/${tripId}/assignments/${assignment.id}/time`, + body: times, resource: 'assignments', entityId: assignment.id, + }) + await cacheAssignment(updated) + }) + return updated + }, } diff --git a/client/src/repo/docSyncRepo.ts b/client/src/repo/docSyncRepo.ts new file mode 100644 index 000000000..4d3e89a2b --- /dev/null +++ b/client/src/repo/docSyncRepo.ts @@ -0,0 +1,44 @@ +import { docsyncApi } from '../api/client' +import { isEffectivelyOffline } from '../sync/networkMode' + +/** + * The two reads behind the Files screen's "Document sync" button. + * + * Online-only with no Dexie table, for the reason `dawarichRepo` gives: both + * answers are the server's (which providers the admin switched on, whether the + * owner bound this trip), and the dialog the button opens cannot load offline + * either. The store in front of this keeps the last answer for the session. + */ +export class DocSyncOfflineError extends Error { + constructor() { + super('Document sync needs a connection') + this.name = 'DocSyncOfflineError' + } +} + +function requireOnline(): void { + if (isEffectivelyOffline()) throw new DocSyncOfflineError() +} + +export const docSyncRepo = { + /** + * Whether the instance offers a provider somebody could connect. + * + * The list is instance-wide; the route sits under a trip only because every + * document-sync route does. `available` is the same filter the dialog + * applies, so a provider row without an adapter does not bring up a button + * into an empty list. + */ + async offersProvider(tripId: number | string): Promise { + requireOnline() + const providers = (await docsyncApi.providers(tripId)) as Array<{ available?: boolean }> + return providers.some(p => p.available) + }, + + /** Whether the trip is bound to at least one store. */ + async isBound(tripId: number | string): Promise { + requireOnline() + const links = (await docsyncApi.listLinks(tripId)) as unknown[] + return links.length > 0 + }, +} diff --git a/client/src/store/docSyncOfferStore.ts b/client/src/store/docSyncOfferStore.ts new file mode 100644 index 000000000..935cec347 --- /dev/null +++ b/client/src/store/docSyncOfferStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand' +import { docSyncRepo } from '../repo/docSyncRepo' + +/** + * Whether a trip's Files screen offers document sync, remembered for the session. + * + * The seed ships every document provider switched off, so a button on every + * trip led, straight after the upgrade, into "No document providers are + * available". It shows where there is something behind it: a trip that is + * already bound (every member needs to see where their documents go), or + * somebody who may bind it on an instance that offers a provider. + * + * Remembered so that coming back to the tab renders the last answer at once, + * instead of the button popping in after a request every time. + */ +interface DocSyncOfferState { + /** Per trip id, whether it has a binding. Missing until asked. */ + bound: Record + /** Whether the instance offers any provider. Null until asked. */ + providers: boolean | null + refresh: (tripId: number | string, canManage: boolean) => Promise +} + +export const useDocSyncOfferStore = create((set) => ({ + bound: {}, + providers: null, + + refresh: async (tripId, canManage) => { + const key = String(tripId) + try { + // Somebody who may bind the trip gets the button as soon as a provider + // is on, bound or not, so the second request is for everybody else. + if (canManage) { + const providers = await docSyncRepo.offersProvider(tripId) + set({ providers }) + if (providers) return + } + const bound = await docSyncRepo.isBound(tripId) + set(s => ({ bound: { ...s.bound, [key]: bound } })) + } catch (err) { + // 403 is the Documents addon switched off and 404 a trip this person no + // longer reaches: both a real "no". Anything else, offline included, is + // no answer at all, and the last one stands. + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 403 || status === 404) set(s => ({ bound: { ...s.bound, [key]: false } })) + } + }, +})) diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 0b367ffd8..26a4d3183 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -3,13 +3,15 @@ import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Assignment, AssignmentsMap } from '../../types' import { getApiErrorMessage } from '../../types' -import { assignmentRepo } from '../../repo/assignmentRepo' +import { assignmentRepo, type AssignmentTimes } from '../../repo/assignmentRepo' +import { mergeAssignmentPlace } from './placesSlice' type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] export interface AssignmentsSlice { setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise + setAssignmentTimes: (tripId: number | string, dayId: number, assignmentId: number, times: AssignmentTimes) => Promise assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise @@ -35,6 +37,29 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment throw err } }, + setAssignmentTimes: async (tripId, dayId, assignmentId, times) => { + const assignment = get().assignments[String(dayId)]?.find(a => a.id === assignmentId) + if (!assignment || assignmentId < 0) return + const replace = (next: Assignment) => set(state => ({ + assignments: Object.fromEntries(Object.entries(state.assignments).map(([key, items]) => [ + key, items.map(a => a.id === assignmentId ? next : a), + ])), + })) + // The embedded place carries the times the visit shows, its own or else the place's, + // so it is merged again from the pool rather than patched field by field. The merged + // row is also what an offline save caches, or the old end time would come back from + // the cache on the next read. + const pool = get().places.find(p => p.id === assignment.place_id) + const merge = (next: Assignment) => (pool ? mergeAssignmentPlace(next, pool) : next) + const draft = merge({ ...assignment, assignment_time: times.place_time, assignment_end_time: times.end_time }) + replace(draft) + try { + replace(merge({ ...draft, ...await assignmentRepo.setTimes(tripId, draft, times) })) + } catch (err: unknown) { + replace(assignment) + throw err + } + }, assignPlaceToDay: async (tripId, dayId, placeId, position) => { const state = get() const place = state.places.find(p => p.id === Number.parseInt(String(placeId))) diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 23d181c13..841a8b13d 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -431,9 +431,10 @@ .trek-dash .col-detail-cover { position: relative; height: 116px; flex-shrink: 0; background-size: cover; background-position: center; } .trek-dash .col-detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .trek-dash .col-detail-cover-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.12) 0%, rgba(0,0,0,.55) 100%); } -.trek-dash .col-detail-cover-cat { position: absolute; top: 12px; left: 14px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: color-mix(in oklch, var(--cat, #6366f1) 82%, black); box-shadow: 0 2px 8px rgba(0,0,0,.3); } +/* chip and close sit in the cover's top bar (CollectionPlaceDetail), which places them */ +.trek-dash .col-detail-cover-cat { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: color-mix(in oklch, var(--cat, #6366f1) 82%, black); box-shadow: 0 2px 8px rgba(0,0,0,.3); } .trek-dash .col-detail-close { - position: absolute; top: 12px; right: 12px; z-index: 2; width: 32px; height: 32px; border-radius: 50%; + width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(0,0,0,.4); color: #fff; border: 1px solid rgba(255,255,255,.25); backdrop-filter: blur(8px); transition: background .12s; } diff --git a/client/src/sync/useServerPing.ts b/client/src/sync/useServerPing.ts new file mode 100644 index 000000000..741a385f2 --- /dev/null +++ b/client/src/sync/useServerPing.ts @@ -0,0 +1,93 @@ +import { useEffect, useRef } from 'react' +import { addListener, removeListener } from '../api/websocket' +import { isEffectivelyOnline, onNetworkModeChange } from './networkMode' + +/** + * How long pings are collected before one refetch goes out. + * + * Long enough to fold a burst into a single request (clearing twelve checked + * items issues twelve deletes, and an import writes a whole list), short enough + * that a single edit still reads as instant. + */ +const COALESCE_MS = 250 + +/** + * Refetch when the server says something moved, without trusting the event to + * carry what moved. + * + * Some events are content-free on purpose: bag weights are summed across members + * whose items this client may not see, and a document-sync run reports counts + * that are only meaningful read back from the server. Every such ping is a + * refetch, which is why this coalesces bursts and never lets two requests + * overlap: the server pings the whole room including the originating socket, so + * a busy trip would otherwise have every client refetching once per written row. + * + * A ping sent while this client is disconnected is gone for good, so the two + * moments where that can have happened count as pings of their own: the room + * re-join after a reconnect, and the return to online. + * + * `reload` may change identity every render; the latest one is always used. + */ +export function useServerPing(eventName: string, enabled: boolean, reload: () => Promise): void { + const reloadRef = useRef(reload) + reloadRef.current = reload + + useEffect(() => { + if (!enabled) return + let timer: ReturnType | null = null + let inFlight = false + /** A ping that arrived while a refetch was running: serve it once that lands. */ + let pendingAfterFlight = false + let cancelled = false + + const run = (): void => { + if (cancelled) return + if (inFlight) { + pendingAfterFlight = true + return + } + inFlight = true + void reloadRef.current().finally(() => { + inFlight = false + if (pendingAfterFlight && !cancelled) { + pendingAfterFlight = false + run() + } + }) + } + + const schedule = (): void => { + if (timer) return // already collecting this burst + timer = setTimeout(() => { + timer = null + run() + }, COALESCE_MS) + } + + const handler = (event: Record) => { + // 'joined' is the room re-join the socket sends on every reconnect, so it + // is also the moment what is on screen is what was there before the drop: + // whatever moved in the meantime pinged into a closed socket. + if (event.type !== eventName && event.type !== 'joined') return + schedule() + } + + let wasOnline = isEffectivelyOnline() + const onMode = (): void => { + const online = isEffectivelyOnline() + // Only the way back matters: that is when the surfaces stop summing what + // this client can see and go back to trusting the server numbers. + if (online && !wasOnline) schedule() + wasOnline = online + } + + addListener(handler) + const unsubscribeMode = onNetworkModeChange(onMode) + return () => { + cancelled = true + removeListener(handler) + unsubscribeMode() + if (timer) clearTimeout(timer) + } + }, [enabled, eventName]) +} diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts index 8608badd6..935bb2c57 100644 --- a/client/src/utils/dayMerge.test.ts +++ b/client/src/utils/dayMerge.test.ts @@ -276,4 +276,30 @@ describe('getMergedItems', () => { const types = result.map(i => i.type) expect(types).toEqual(['place', 'transport', 'place']) }) + + // The same rule the server stores when a start time is saved, so on a day of places + // alone the day it sends back after the save and the day drawn before it agree. + it('keeps untimed places where they were put and sorts only the timed ones', () => { + const place = (id: number, order_index: number, place_time: string | null) => ({ id, order_index, place: { place_time } }) + const ids = (dayAssignments: ReturnType[]) => + getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [], dayId: 5 }).map(i => i.data.id) + + expect(ids([place(1, 0, null), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3]) + expect(ids([place(1, 0, '09:00'), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3]) + expect(ids([place(1, 0, null), place(2, 1, '15:00'), place(3, 2, '10:00')])).toEqual([1, 3, 2]) + }) + + // Where the server's sort and this one part: the server sorts the stops alone, so + // for it place 2 follows the 09:00 place and stays in front of the 10:00 one. Here it + // follows the 12:00 note and is drawn behind both. + it('lets an untimed place take the time of a timed note in front of it', () => { + const dayAssignments = [ + { id: 1, order_index: 0, place: { place_time: '09:00' } }, + { id: 2, order_index: 1, place: { place_time: null } }, + { id: 3, order_index: 2, place: { place_time: '10:00' } }, + ] + const dayNotes = [{ id: 10, sort_order: 0.5, time: '12:00' }] + const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 }) + expect(result.map(i => i.data.id)).toEqual([1, 3, 10, 2]) + }) }) diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts index 430ad054b..70395498c 100644 --- a/client/src/utils/dayMerge.ts +++ b/client/src/utils/dayMerge.ts @@ -1,3 +1,4 @@ +import { chronoOrder } from '@trek/shared' // `orderedEndpoints` is the geometry order's single source of truth, in the module // that documents the multi-leg model. Sorting endpoints a second time here is how // the two drift. @@ -270,26 +271,22 @@ export function getAssignmentReservations string | null ): MergedItem[] { - const timeOf = (it: MergedItem): number | null => { + return chronoOrder(items, it => { if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time) if (it.type === 'note') return parseTimeToMinutes(it.data?.time) return parseTimeToMinutes(getDisplayTime(it.data, dayId)) - } - let last = -Infinity - return items - .map((it, i) => { - const t = timeOf(it) - if (t != null) last = t - return { it, i, eff: t != null ? t : last } - }) - .sort((a, b) => a.eff - b.eff || a.i - b.i) - .map(k => k.it) + }) } /** Merge places, notes, and transports into a single ordered day timeline. */ diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index 7a9d2ee74..a4b2c1756 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -325,6 +325,7 @@ export function buildShell(overrides: Partial = {}): MTripShellAp exportCostsCsvSignal: 0, uploadFilesSignal: 0, openFilesTrashSignal: 0, + openDocSyncSignal: 0, }; return { ...base, ...overrides }; } diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index 96bbb29fe..c369aacb8 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -1,4 +1,4 @@ -// FE-MOB-AADD-001 to FE-MOB-AADD-025 +// FE-MOB-AADD-001 to FE-MOB-AADD-031 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -356,6 +356,104 @@ describe('MAdminAddonManager', () => { expect(screen.getByRole('switch', { name: 'Todo List' })).toHaveAttribute('aria-checked', 'true'); }); + describe('document providers', () => { + const documents = (enabled = true) => + buildAddon({ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', icon: 'FileText', enabled }); + const docProvider = (id: string, name: string, enabled: boolean) => + buildAddon({ id, name, description: `${name} archive`, icon: id, type: 'document_provider', enabled }); + + it('FE-MOB-AADD-028: they render as sub-rows under the Documents addon, not as addons of their own', async () => { + server.use( + addonsRoute([ + documents(), + docProvider('paperless', 'Paperless-ngx', false), + docProvider('nextcloud', 'Nextcloud', true), + ]), + ); + render(); + + await screen.findByText('Paperless-ngx'); + expect(screen.getByText('Nextcloud archive')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Nextcloud' })).toHaveAttribute('aria-checked', 'true'); + // Documents plus two providers, and neither provider carries a type badge of its own. + expect(screen.getAllByRole('switch')).toHaveLength(3); + expect(screen.getAllByText('Trip')).toHaveLength(1); + }); + + it('FE-MOB-AADD-029: the shelf stays hidden while Documents is off', async () => { + server.use(addonsRoute([documents(false), docProvider('paperless', 'Paperless-ngx', false)])); + render(); + + await screen.findByText('Documents'); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + expect(screen.getAllByRole('switch')).toHaveLength(1); + }); + + it('FE-MOB-AADD-030: toggling one persists it and rolls back on failure', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + addonsRoute([ + documents(), + docProvider('paperless', 'Paperless-ngx', false), + docProvider('papra', 'Papra', false), + ]), + http.put('/api/admin/addons/paperless', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + http.put('/api/admin/addons/papra', () => HttpResponse.error()), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(screen.getByRole('switch', { name: 'Paperless-ngx' })); + await waitFor(() => expect(body).toEqual({ enabled: true })); + expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'true'); + await screen.findByText('Addon updated'); + expect(loadAddonsSpy).toHaveBeenCalled(); + + await user.click(screen.getByRole('switch', { name: 'Papra' })); + await screen.findByText('Failed to update addon'); + await waitFor(() => expect(screen.getByRole('switch', { name: 'Papra' })).toHaveAttribute('aria-checked', 'false')); + expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-AADD-031: switching Documents off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let documentsOn = true; + let paperlessOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [documents(documentsOn), docProvider('paperless', 'Paperless-ngx', paperlessOn)], + }); + }), + http.put('/api/admin/addons/documents', async ({ request }) => { + documentsOn = (await request.json() as { enabled: boolean }).enabled; + // Documents off takes its providers with it, see admin.service.ts. + if (!documentsOn) paperlessOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(screen.getByRole('switch', { name: 'Documents' })); + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('switch', { name: 'Documents' })); + + await screen.findByText('Paperless-ngx'); + expect(screen.getByRole('switch', { name: 'Paperless-ngx' })).toHaveAttribute('aria-checked', 'false'); + }); + }); + it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => { server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false })))); render(); diff --git a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx index 18431cb83..16c847c47 100644 --- a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx @@ -8,7 +8,7 @@ import type { Category } from '../../../../src/types' import { resetAllStores } from '../../../helpers/store' import { useTranslation } from '../../../../src/i18n' -// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-037 +// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-041 // react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs // the raw description text to reach the renderer. @@ -418,4 +418,64 @@ describe('MCollPlaceSheet', () => { fireEvent.click(screen.getByRole('button', { name: 'Edit' })) expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama') }) + + // ── Cover controls beside the close button ───────────────────────────────── + + it('FE-MOB-CPLSH-038: the camera sits in the top right row, just left of close, and looks like it', () => { + setup({ onUploadImage: vi.fn() }) + const camera = screen.getByRole('button', { name: 'Upload image' }) + const close = screen.getByRole('button', { name: 'Close' }) + + const row = close.parentElement as HTMLElement + expect(camera.parentElement).toBe(row) + expect(camera.nextElementSibling).toBe(close) + expect(camera.className).toBe(close.className) + // The category chip has the top left corner to itself again. + expect(row.contains(screen.getByText('Restaurant'))).toBe(false) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const clicked = vi.fn() + input.addEventListener('click', clicked) + fireEvent.click(camera) + expect(clicked).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-CPLSH-039: with a custom cover the row reads remove, change, close', () => { + setup({ onUploadImage: vi.fn(), place: place({ image_url: '/uploads/places/n.jpg' }) }) + const remove = screen.getByRole('button', { name: 'Remove image' }) + const change = screen.getByRole('button', { name: 'Change image' }) + const close = screen.getByRole('button', { name: 'Close' }) + + expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]) + expect(remove.className).toBe(close.className) + }) + + it('FE-MOB-CPLSH-040: without a category chip the name still starts below the full control row', () => { + setup({ onUploadImage: vi.fn(), place: place({ category: undefined, category_id: null, image_url: '/uploads/places/n.jpg' }) }) + const controls = screen.getByRole('button', { name: 'Close' }).parentElement as HTMLElement + const top = controls.parentElement as HTMLElement + + expect(controls.querySelectorAll('button')).toHaveLength(3) + // The row is in flow rather than pinned over the hero, and the name follows it. + expect(top).not.toHaveClass('absolute') + expect(controls).not.toHaveClass('absolute') + expect(Array.from(top.children)).toEqual([controls]) + expect(top.nextElementSibling).toBe(screen.getByText('Narisawa')) + }) + + it('FE-MOB-CPLSH-041: a long category ellipsizes against the controls instead of running under them', () => { + const long = 'Restaurants, cafes and late night bars' + setup({ + onUploadImage: vi.fn(), + place: place({ category: { id: 3, name: long, color: '#E8843D', icon: 'Utensils' }, image_url: '/uploads/places/n.jpg' }), + }) + const controls = screen.getByRole('button', { name: 'Close' }).parentElement as HTMLElement + const text = screen.getByText(long) + const chip = text.parentElement as HTMLElement + + expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([chip, controls]) + expect(controls).toHaveClass('ms-auto', 'flex-none') + expect(chip).toHaveClass('min-w-0') + expect(text).toHaveClass('truncate') + }) }) diff --git a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx index b7e9f8f9f..3902c3a2b 100644 --- a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx +++ b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx @@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { useSettingsStore } from '../../../../src/store/settingsStore' +import { useAuthStore } from '../../../../src/store/authStore' +import { usePermissionsStore } from '../../../../src/store/permissionsStore' +import { useTripStore } from '../../../../src/store/tripStore' import type { RoadtripDay } from '@trek/shared/roadtrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-018 +// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-025 // // The sheet renders inside the real TranslationProvider, so the copy is asserted // in English. @@ -229,4 +232,93 @@ describe('MRtStaySheet', () => { expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument() expect(screen.queryByText('Arrive')).not.toBeInTheDocument() }) + + describe('a stop left at a set time', () => { + // Bremen's End is 23:45, so the stay is whatever the arrival at 22:30 leaves of it, + // and there is nothing for the buttons to set. + const LEAVING = { + ...STAGE, + stops: [STAGE.stops[0], { ...STAGE.stops[1], leaveAt: '23:45' }], + schedule: { + entries: [STAGE.schedule.entries[0], { arrival: '22:30', departure: '23:45', anchored: false, dayOffset: 0 }], + warnings: [], + }, + } as unknown as RoadtripDay + let setAssignmentTimes: ReturnType + + beforeEach(() => { + setAssignmentTimes = vi.fn(async () => undefined) + seedStore(useAuthStore, { user: { id: 1, role: 'user' } }) + seedStore(useTripStore, { + trip: { id: 4, user_id: 1 }, + assignments: { '11': [{ id: 102, day_id: 11, place_id: 202, order_index: 1, assignment_time: '22:00', assignment_end_time: '23:45' }] }, + }) + useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never) + }) + + it('FE-MOB-RTSTAY-020: says when the drive leaves and what stay that makes, with nothing to set', () => { + renderSheet({ roadtripRoutes: { days: [LEAVING] } }) + // Named for what it holds, as the desktop dialog is: there is no stay to add here. + expect(screen.getByRole('dialog', { name: 'Time at this stop' })).toBeInTheDocument() + expect(screen.getByText('Time at this stop')).toBeInTheDocument() + expect(screen.queryByText('Add a stay')).not.toBeInTheDocument() + expect(screen.getByText('1 h 15 min')).toBeInTheDocument() + expect(screen.getByText('This visit has an end time, so the drive leaves at 23:45.')).toBeInTheDocument() + expect(screen.getByText('22:30')).toBeInTheDocument() + expect(screen.getByText('23:45')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '5 minutes more' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + + it('FE-MOB-RTSTAY-021: taking the end time off keeps the Start and writes through the visit', async () => { + const { planner } = renderSheet({ roadtripRoutes: { days: [LEAVING] } }) + fireEvent.click(screen.getByRole('button', { name: 'Remove end time' })) + await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '22:00', end_time: null })) + // The ordinary sheet is back, on the stay the place carries. + await waitFor(() => expect(value()).toHaveTextContent('30')) + expect(planner.setRoadtripStay).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTSTAY-022: a traveller who may not change the day is not offered the removal', () => { + usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } }) + seedStore(useTripStore, { trip: { id: 4, user_id: 2 } }) + renderSheet({ roadtripRoutes: { days: [LEAVING] } }) + expect(screen.getByText('1 h 15 min')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument() + }) + + it('FE-MOB-RTSTAY-024: reached after the time, it says so instead of promising it', () => { + const late = { + ...LEAVING, + schedule: { + entries: [STAGE.schedule.entries[0], { arrival: '23:55', departure: '23:55', anchored: false, dayOffset: 0 }], + warnings: [{ index: 1, code: 'missedLeave', minutes: 10 }], + }, + } as unknown as RoadtripDay + renderSheet({ roadtripRoutes: { days: [late] } }) + expect(screen.getByText('Arrives 10 min after the time you set to leave')).toBeInTheDocument() + expect(screen.queryByText('This visit has an end time, so the drive leaves at 23:45.')).not.toBeInTheDocument() + expect(screen.getAllByText('23:55')).toHaveLength(2) + }) + + it('FE-MOB-RTSTAY-025: travel hours over before the time say so, with no departure beside it', () => { + const closed = { + ...LEAVING, + schedule: { + entries: [STAGE.schedule.entries[0], { arrival: '17:30', departure: '18:00', anchored: false, dayOffset: 0 }], + warnings: [], + }, + } as unknown as RoadtripDay + renderSheet({ roadtripRoutes: { days: [closed] } }) + expect(screen.getByText('The travel day ends before 23:45, so the drive goes on the next morning.')).toBeInTheDocument() + expect(screen.queryByText('18:00')).not.toBeInTheDocument() + }) + + it('FE-MOB-RTSTAY-023: the close in the header writes nothing', () => { + const { shell } = renderSheet({ roadtripRoutes: { days: [LEAVING] } }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(shell.closeSheet).toHaveBeenCalledTimes(1) + expect(setAssignmentTimes).not.toHaveBeenCalled() + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx index c226e3e8e..d23cdd740 100644 --- a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx @@ -8,7 +8,7 @@ import { useTripStore } from '../../../../src/store/tripStore' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-035 +// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-038 // // The sheet renders inside the real TranslationProvider, so the copy is asserted // in English. `planner.t` from the fixture is never consulted here. @@ -511,4 +511,50 @@ describe('MRtStopSheet', () => { expect(shell.closeSheet).toHaveBeenCalledTimes(1) expect(planner.setSelectedPlaceId).not.toHaveBeenCalled() }) + + describe('a stop left at a set time', () => { + // Lueneburg carries a stay of 30 min, but its End is two in the afternoon: the drive + // gets there at ten and keeps to the End. + const LUENEBURG = stop({ assignmentId: 105, placeId: 205, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }) + const leaving = (arrival: string, departure: string, warnings: unknown[] = []) => ({ + ...DAY_A, + stops: [HAMBURG, LUENEBURG], + schedule: { + entries: [ + { arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 }, + { arrival, departure, anchored: false, dayOffset: 0 }, + ], + warnings, + }, + driveWarnings: [], + }) as unknown as RoadtripDay + const open = (day: RoadtripDay) => + renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 105 } } }) + + it('FE-MOB-RTSTOP-036: reads the stay the End makes, and until when, on the finding and the tile', () => { + open(leaving('10:00', '14:00')) + expect(screen.getAllByText('4 h until 14:00')).toHaveLength(2) + expect(screen.queryByText('30 min')).not.toBeInTheDocument() + }) + + it('FE-MOB-RTSTOP-037: under the two clocks it says the drive leaves at that time', () => { + open(leaving('10:00', '14:00')) + expect(screen.getByText('14:00')).toBeInTheDocument() + expect(screen.getByText('This visit has an end time, so the drive leaves at 14:00.')).toBeInTheDocument() + }) + + it('FE-MOB-RTSTOP-038: reached after it, the stop says by how much', () => { + open(leaving('14:30', '14:30', [{ index: 1, code: 'missedLeave', minutes: 30 }])) + expect(screen.getByText('Arrives 30 min after the time you set to leave')).toBeInTheDocument() + expect(screen.getAllByText('0 min until 14:00')).toHaveLength(2) + // Left at 14:30, so nothing under the clocks promises 14:00. + expect(screen.queryByText('This visit has an end time, so the drive leaves at 14:00.')).not.toBeInTheDocument() + }) + + it('FE-MOB-RTSTOP-039: with the travel hours over first, it reads until when and says the drive goes on in the morning', () => { + open(leaving('10:00', '12:00')) + expect(screen.getAllByText('until 14:00')).toHaveLength(2) + expect(screen.getByText('The travel day ends before 14:00, so the drive goes on the next morning.')).toBeInTheDocument() + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx index 96b9893e0..6082b038b 100644 --- a/client/tests/unit/mobile/trip/MTripShell.test.tsx +++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx @@ -1,10 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, within } from '../../../helpers/render' +import { http, HttpResponse } from 'msw' +import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' +import { server } from '../../../helpers/msw/server' +import { seedStore } from '../../../helpers/store' +import { buildUser } from '../../../helpers/factories' +import { useAuthStore } from '../../../../src/store/authStore' +import { useDocSyncOfferStore } from '../../../../src/store/docSyncOfferStore' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { Day, PackingItem, TodoItem } from '../../../../src/types' -// FE-MOB-SHELL-001 to FE-MOB-SHELL-066 +// FE-MOB-SHELL-001 to FE-MOB-SHELL-068 const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner })) @@ -120,6 +126,13 @@ const spy = (planner: TripPlanner, name: keyof TripPlanner) => describe('MTripShell', () => { beforeEach(() => { sessionStorage.clear() + // Document sync as a fresh install has it: every provider off, nothing bound. + useDocSyncOfferStore.setState({ bound: {}, providers: null }) + seedStore(useAuthStore, { user: null, isAuthenticated: false }) + server.use( + http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])), + http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([])), + ) }) it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => { @@ -455,6 +468,32 @@ describe('MTripShell', () => { expect(shellApi.openFilesTrashSignal).toBe(1) }) + it('FE-MOB-SHELL-067: the files header leaves the sync button out while no provider is on and nothing is bound', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 1, role: 'user' }), isAuthenticated: true }) + const asked: string[] = [] + server.use( + http.get('/api/trips/:tripId/docsync/links', () => { + asked.push('links') + return HttpResponse.json([]) + }), + ) + renderShell({ activeTab: 'dateien' } as Partial) + + await waitFor(() => expect(asked).toContain('links')) + expect(screen.queryByRole('button', { name: 'docsync.title' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.upload' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'files.trash' })).toBeInTheDocument() + }) + + it('FE-MOB-SHELL-068: a bound trip shows the sync button to a member, and it raises the open signal', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true }) + server.use(http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }]))) + renderShell({ activeTab: 'dateien' } as Partial) + + fireEvent.click(await screen.findByRole('button', { name: 'docsync.title' })) + expect(shellApi.openDocSyncSignal).toBe(1) + }) + it('FE-MOB-SHELL-034: the lists header shows packed/open counts and persists the sub-tab', () => { const packingItems = [ { id: 1, checked: true }, { id: 2, checked: false }, { id: 3, checked: true }, diff --git a/client/tests/unit/mobile/trip/PlTimeFields.test.tsx b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx index 36d21e983..7bfa12667 100644 --- a/client/tests/unit/mobile/trip/PlTimeFields.test.tsx +++ b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx @@ -7,7 +7,7 @@ import { seedStore } from '../../../helpers/store' import { buildSettings } from '../../../helpers/factories' import { useSettingsStore } from '../../../../src/store/settingsStore' -// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-013 +// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-015 const planner = buildPlanner() @@ -151,4 +151,24 @@ describe('PlTimeFields', () => { setup({ endTime: '11:0', dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00')] }) expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument() }) + + it('FE-MOB-PLTIME-014: opened from the road trip tab, End says the drive leaves at it', () => { + render( + , + ) + expect(screen.getByText('roadtrip.stop.endIsLeave')).toBeInTheDocument() + }) + + it('FE-MOB-PLTIME-015: from the plan tab End stays a plain label', () => { + setup() + expect(screen.queryByText('roadtrip.stop.endIsLeave')).not.toBeInTheDocument() + }) }) diff --git a/client/tests/unit/slices/assignmentsSlice.test.ts b/client/tests/unit/slices/assignmentsSlice.test.ts index e694761cb..c8e9dc12b 100644 --- a/client/tests/unit/slices/assignmentsSlice.test.ts +++ b/client/tests/unit/slices/assignmentsSlice.test.ts @@ -394,6 +394,55 @@ describe('assignmentsSlice', () => { }); }); + describe('setAssignmentTimes', () => { + // The pool place still carries an End of its own, which the visit shows once its + // own override is gone, exactly as a fresh read of the day would. + const pool = () => buildPlace({ id: 10, trip_id: 1, place_time: null, end_time: '18:00' }); + const visit = () => buildAssignment({ + id: 5, day_id: 1, place_id: 10, assignment_time: '09:00', assignment_end_time: '14:00', + place: { ...pool(), place_time: '09:00', end_time: '14:00' }, + }); + + it('FE-ASSIGN-020: clears the End at once, with the place End showing through, and keeps the Start', async () => { + seedStore(useTripStore, { places: [pool()], assignments: { '1': [visit()] } }); + let seen: unknown; + server.use( + http.put('/api/trips/1/assignments/5/time', async ({ request }) => { + seen = await request.json(); + const mid = useTripStore.getState().assignments['1'][0]; + expect(mid).toMatchObject({ assignment_end_time: null, place: { end_time: '18:00', place_time: '09:00' } }); + return HttpResponse.json({ assignment: { ...visit(), assignment_end_time: null, place: { ...pool(), place_time: '09:00' } } }); + }), + ); + + await useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: '09:00', end_time: null }); + + expect(seen).toEqual({ place_time: '09:00', end_time: null }); + expect(useTripStore.getState().assignments['1'][0]).toMatchObject({ + assignment_time: '09:00', assignment_end_time: null, place: { end_time: '18:00' }, + }); + }); + + it('FE-ASSIGN-021: a refused write puts the visit back as it was', async () => { + seedStore(useTripStore, { places: [pool()], assignments: { '1': [visit()] } }); + server.use(http.put('/api/trips/1/assignments/5/time', () => HttpResponse.json({ error: 'no' }, { status: 403 }))); + + await expect(useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: '09:00', end_time: null })).rejects.toBeTruthy(); + + expect(useTripStore.getState().assignments['1'][0]).toMatchObject({ assignment_end_time: '14:00', place: { end_time: '14:00' } }); + }); + + it('FE-ASSIGN-022: a visit it cannot find, or one not yet saved, is left alone', async () => { + const unsaved = { ...visit(), id: -3 }; + seedStore(useTripStore, { places: [pool()], assignments: { '1': [unsaved] } }); + + await useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: null, end_time: null }); + await useTripStore.getState().setAssignmentTimes(1, 1, -3, { place_time: null, end_time: null }); + + expect(useTripStore.getState().assignments['1'][0]).toEqual(unsaved); + }); + }); + describe('setAssignments', () => { it('FE-ASSIGN-019: replaces the whole assignments map', () => { seedStore(useTripStore, { assignments: { '1': [buildAssignment({ id: 1, day_id: 1 })] } }); diff --git a/server/src/addons.ts b/server/src/addons.ts index 14c897f8d..4337eec31 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -42,6 +42,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [ ADDON_IDS.COLLAB, ADDON_IDS.COLLECTIONS, ADDON_IDS.DAWARICH, + ADDON_IDS.DOCUMENTS, ADDON_IDS.JOURNEY, ADDON_IDS.PACKING, ADDON_IDS.ROADTRIP, diff --git a/server/src/db/document-provider-seed.ts b/server/src/db/document-provider-seed.ts new file mode 100644 index 000000000..16e2467a8 --- /dev/null +++ b/server/src/db/document-provider-seed.ts @@ -0,0 +1,137 @@ +import type Database from 'better-sqlite3'; + +/** + * The five document providers and the fields their connection form asks for. + * + * Shared by the migration that introduces the tables and by `seeds.ts`, so a + * fresh install and an upgraded one end up with the same rows. Both call sites + * use INSERT OR IGNORE, which is what lets an operator rename or re-sort a row + * without a later boot undoing it. + * + * `label` and `hint` are i18n key suffixes, never display text: the client + * resolves them as `docsync.