Merge pull request #2391 from liketrek/feat/document-providers

feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
This commit is contained in:
Maurice
2026-09-19 19:57:16 +02:00
committed by GitHub
226 changed files with 36514 additions and 460 deletions
+8 -1
View File
@@ -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', () => {
+30
View File
@@ -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),
+6
View File
@@ -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',
@@ -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(<AddonManager />);
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(<AddonManager />);
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(<><ToastContainer /><AddonManager /></>);
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(<><ToastContainer /><AddonManager /></>);
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 })]));
+39 -2
View File
@@ -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<string, ComponentType<{ size?: number }>> = {
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 (
<AddonSubRow
key={provider.key}
icon={ProviderIcon ? <ProviderIcon size={14} /> : 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 (
@@ -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<void>(() => {}));
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');
});
});
@@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
{canEdit && onUploadImage && (
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
</button>
</Tooltip>
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
<Trash2 size={15} />
</button>
</Tooltip>
{/* 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. */}
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
{place.category?.name && (
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} className="flex-none" />
<span className="truncate">{place.category.name}</span>
</span>
)}
{/* The cover controls wear the close button's class, so they get its look,
hover and phone size. */}
<div className="ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button type="button" className="col-detail-close" onClick={handleImageRemove} aria-label={t('places.removeImage')}>
<Trash2 size={16} />
</button>
</Tooltip>
)}
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
className="col-detail-close"
style={imgBusy ? { cursor: 'default' } : undefined}
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
>
{imgBusy ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
</button>
</Tooltip>
</>
)}
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
</div>
)}
</div>
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
@@ -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(<FileManager {...defaultProps} />);
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(<FileManager {...defaultProps} />);
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(<FileManager {...defaultProps} />);
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(<FileManager {...defaultProps} />);
await waitFor(() => expect(asked).toContain('links'));
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
});
});
});
@@ -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) {
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* 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 && (
<DocSyncPanel
tripId={props.tripId}
tripTitle={S.trip?.title}
canManage={S.canManageSync}
onClose={() => S.setShowDocSync(false)}
/>
)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -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 (
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
@@ -63,12 +63,29 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
{/* 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 && (
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
}}>
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
</button>
)}
{/* The first of the two takes the free space, so without the sync
button the trash sits flush right the way it always has. */}
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
flexShrink: 0, marginLeft: docSyncOffered ? undefined : 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -0,0 +1,348 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-026
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }))
const updateLink = vi.fn(async (_linkId: number, _patch: Record<string, unknown>) => {})
const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
connectionId: 1,
providerId: 'paperless',
scopeKey: 'trek-trip-3',
remoteLabel: 'Lisbon papers',
remoteRootPath: '/TREK/Lisbon',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 },
...overrides,
}
}
function makeSync(overrides: Partial<Sync> = {}): Sync {
return {
providers: [],
connections: [],
links: [],
itemCounts: {},
loading: false,
busy: null,
error: null,
connectionFor: () => null,
load: vi.fn(),
saveConnection: vi.fn(),
testConnection: vi.fn(),
loadScopes: vi.fn(),
createScope: vi.fn(),
createLink: vi.fn(),
updateLink,
removeLink,
syncNow,
resolveConflict: vi.fn(),
lastRunFor: () => ({ pulled: 0, pushed: 0 }),
...overrides,
} as unknown as Sync
}
function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
canManage?: boolean
providerName?: string
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
providerName={opts.providerName ?? 'Paperless'}
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
return { ...utils, header }
}
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding: running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
expect(syncNow).toHaveBeenCalledWith(4)
})
it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => {
renderCard({ sync: { busy: 'sync-4' } })
const button = screen.getByRole('button', { name: 'Syncing' })
expect(button).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
fireEvent.click(button)
expect(syncNow).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => {
renderCard({ sync: { busy: 'sync-9' } })
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-004: the disconnect button unlinks this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
expect(removeLink).toHaveBeenCalledWith(4)
})
})
describe('DocSyncBinding: what it says about itself', () => {
it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => {
renderCard()
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
const dot = screen.getByTitle('In sync')
expect(dot).toBeInTheDocument()
expect(dot).toBeEmptyDOMElement()
})
it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } })
// once as the header badge, once as the banner underneath it
expect(screen.getAllByText('Failed')).toHaveLength(2)
})
it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => {
renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } })
expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => {
renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } })
expect(screen.getAllByText('Not synced yet')).toHaveLength(1)
expect(screen.getByText('not run yet')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-026: a binding whose provider an admin switched off says it is paused, not what the last run said', () => {
// The server leaves the binding's own state as the last run wrote it, so
// without this the card went on reporting an old failure, or nothing.
renderCard({ link: { providerOff: true, lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
expect(screen.queryByText('Failed · The credentials were refused.')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
const { unmount } = renderCard({ link: { syncEnabled: false } })
expect(screen.getByText('Paused')).toBeInTheDocument()
unmount()
renderCard({ link: { syncEnabled: true } })
expect(screen.queryByText('Paused')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => {
renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } })
expect(screen.getByText(/hours ago/)).toBeInTheDocument()
expect(screen.queryByText('not run yet')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => {
const { unmount } = renderCard({ link: { remoteLabel: '' } })
expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument()
unmount()
renderCard({ link: { remoteLabel: '', remoteRootPath: null } })
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: names the store it is handed in the header and at the far end of the flow', () => {
const { header } = renderCard({ providerName: 'Paperless-ngx' })
expect(within(header).getByText('Paperless-ngx')).toBeInTheDocument()
expect(screen.getAllByText('Paperless-ngx')).toHaveLength(2)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => {
const { unmount } = renderCard()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
unmount()
renderCard({ link: { holdings: undefined } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
})
describe('DocSyncBinding: the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
const toggle = screen.getByRole('button', { name: 'Settings' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Sync automatically')).toBeInTheDocument()
expect(screen.getByText('When a document is deleted')).toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ canManage: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
// a member may still trigger a run
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => {
renderCard()
openSettings()
fireEvent.click(within(rowFor('Sync automatically')).getByRole('button'))
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false })
})
it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => {
renderCard({ link: { syncEnabled: false } })
openSettings()
const toggle = within(rowFor('Sync automatically')).getByRole('button')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true })
})
it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => {
renderCard()
openSettings()
const trigger = within(rowFor('When a document is deleted')).getByRole('button')
expect(trigger).toHaveTextContent('Keep both copies')
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' }))
expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' })
})
})
describe('DocSyncBinding: the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true })
})
const stubClipboard = (writeText: (text: string) => Promise<void>) => {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
}
it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => {
renderCard({ link: { webhookUrl: null } })
openSettings()
expect(screen.queryByText('Instant updates')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => {
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
expect(screen.getByText('Instant updates')).toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => {
const writeText = vi.fn(async (_text: string) => {})
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => {
const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') })
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
})
describe('DocSyncBinding: the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Out to the store' }))
expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' })
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ canManage: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
fireEvent.click(lane)
expect(updateLink).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,220 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
} from 'lucide-react'
import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
import { bindingNotice, type DocSyncLink, type useDocSync } from './useDocSync'
/**
* One binding: where this trip's documents live, which way they move, and what
* happened last time.
*
* The flow bar carries the direction because that is the one thing this feature
* is about. Everything a person sets once and forgets (the delete rule, the
* webhook URL) is folded away behind "Settings" so the card stays a status
* card rather than a form.
*/
export default function DocSyncBinding({
link,
providerName,
sync,
canManage,
}: {
link: DocSyncLink
providerName: string
sync: ReturnType<typeof useDocSync>
canManage: boolean
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
const busy = sync.busy === `sync-${link.id}`
const notice = bindingNotice(link, t)
const copyWebhook = async () => {
if (!link.webhookUrl) return
try {
await navigator.clipboard.writeText(link.webhookUrl)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch {
// Clipboard is blocked outside a secure context; the URL is selectable.
}
}
return (
<article className="overflow-hidden rounded-2xl border border-edge bg-surface shadow-card">
<header className="flex flex-wrap items-start gap-x-3 gap-y-3 px-4 py-3.5">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-edge bg-surface-secondary">
{Icon ? <Icon className="h-5 w-5 text-content" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-subtitle font-semibold text-content">
{providerName}
</span>
{/* A healthy binding needs no word for it: the dot is the whole
message, and the states that do need words get them below. */}
<StateBadge state={link.lastSyncState} compact={link.lastSyncState === 'ok'} />
{!link.syncEnabled && <Badge tone="neutral">{t('docsync.binding.autoOff')}</Badge>}
</span>
{/* Where and when, as two facts rather than a sentence. They are what
a member checks before asking why a file has not turned up. */}
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Badge tone="neutral" icon={<FolderOpen size={12} />} title={link.remoteRootPath || undefined}>
<span className="max-w-[12rem] truncate">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</span>
</Badge>
<Badge tone="neutral" icon={<Clock size={12} />}>
<LastRun at={link.lastSyncAt} />
</Badge>
</span>
</span>
{/* Both controls are h-9 so the square one cannot sit a pixel off the
other: the two together read as one segmented control. */}
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-9 items-center gap-2 rounded-lg border border-edge bg-surface px-3 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-60"
>
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />
{/* Taken out of the layout below sm rather than out of the document:
`hidden` left a screen reader announcing a bare "button" there,
and an aria-label would have frozen the name while the visible
text still changes to "Syncing". */}
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
</button>
{canManage && (
<Tooltip label={t('docsync.unlink')}>
<button
type="button"
onClick={() => void sync.removeLink(link.id)}
className="grid h-9 w-9 place-items-center rounded-lg border border-edge bg-surface text-content-muted transition-colors hover:bg-danger-soft hover:text-danger"
aria-label={t('docsync.unlink')}
>
<Link2Off size={14} />
</button>
</Tooltip>
)}
</span>
</header>
{notice && (
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-px shrink-0" />
<span>{notice}</span>
</p>
)}
<div className="px-4 pb-3">
<DocSyncFlow
direction={link.direction}
providerId={link.providerId}
providerName={providerName}
running={busy}
holdings={link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }}
disabled={!canManage}
onChange={(next: SyncDirection) => void sync.updateLink(link.id, { direction: next })}
/>
</div>
{canManage && (
<>
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-left text-caption text-content-muted transition-colors hover:bg-surface-hover"
>
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
{t('docsync.binding.settings')}
</button>
{open && (
<div className="space-y-3 border-t border-edge-faint bg-surface-secondary px-4 py-3">
<Row label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<CustomSelect
size="sm"
value={link.deletePolicy}
onChange={v => void sync.updateLink(link.id, { deletePolicy: String(v) })}
options={[
{ value: 'unlink', label: t('docsync.deleteUnlink') },
{ value: 'trash', label: t('docsync.deleteTrash') },
]}
/>
</Row>
<Row label={t('docsync.conflictPolicy')} hint={t('docsync.binding.conflictHint')}>
<CustomSelect
size="sm"
value={link.conflictPolicy}
onChange={v => void sync.updateLink(link.id, { conflictPolicy: String(v) })}
options={CONFLICT_POLICIES.map(p => ({ value: p, label: t(conflictPolicyKey(p)) }))}
/>
</Row>
<Row label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<span className="flex justify-end">
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</span>
</Row>
{/* 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 && (
<div>
{/* Same tier as the two rows above it: this is the third
setting, not a footnote to the second. */}
<span className="block text-body text-content">{t('docsync.binding.webhookTitle')}</span>
<p className="mt-1 text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<div className="mt-1.5 flex items-stretch gap-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content-secondary">
{link.webhookUrl}
</code>
<button
type="button"
onClick={() => void copyWebhook()}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 text-caption text-content-secondary transition-colors hover:bg-surface-hover"
>
{copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
{copied ? t('docsync.binding.copied') : t('docsync.binding.copy')}
</button>
</div>
</div>
)}
</div>
)}
</>
)}
</article>
)
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-body text-content">{label}</span>
{hint && <span className="mt-0.5 block text-caption text-content-muted">{hint}</span>}
</span>
<span className="w-56 shrink-0">{children}</span>
</div>
)
}
@@ -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<Tone, string> = {
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 (
<span
title={title}
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}
>
{icon}
{children}
</span>
)
}
/** 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 <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} title={t(`docsync.linkState.${state}`)} />
return (
<Badge tone={tone} icon={<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}>
{t(`docsync.linkState.${state}`)}
</Badge>
)
}
/** "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 <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <span title={new Date(ms).toLocaleString(locale)}>{relativeTime(ms, language)}</span>
}
/**
* 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'}`
}
@@ -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<string, string>)[key] ?? key
const providers = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listConnections = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
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> = {}): 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 <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
}
/** 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(
<Harness provider={provider} onClose={onClose} onConnected={onConnected} waitForConnection={waitForConnection} />,
)
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' }))
})
})
@@ -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<typeof useDocSync>
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 (
<Modal
isOpen
onClose={onClose}
size="md"
title={
<span className="flex items-center gap-2.5">
{Icon && <Icon className="h-5 w-5 text-content" />}
<span>{provider.name}</span>
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void test()}
disabled={!form.complete || sync.busy === 'test'}
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.test')}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!form.complete || sync.busy === 'save'}
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
{t('docsync.connect.submit')}
</button>
</div>
</div>
}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{form.fields.map(f => (
<Field
key={f.field_key}
label={t(form.labelKey(f))}
hint={form.hintKey(f) ? t(form.hintKey(f) as string) : undefined}
required={f.required}
>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={form.valueOf(f.field_key)}
placeholder={form.placeholderOf(f)}
onChange={e => 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"
/>
</Field>
))}
<label className="flex items-start justify-between gap-4 rounded-lg border border-edge bg-surface-secondary px-3 py-2.5">
<span className="min-w-0">
<span className="block text-body text-content">{t('docsync.allowInsecureTls')}</span>
<span className="mt-0.5 block text-caption text-content-muted">
{t('docsync.connect.insecureHint')}
</span>
</span>
<span className="shrink-0 pt-0.5">
<ToggleSwitch on={form.insecureTls} onToggle={() => form.setInsecureTls(!form.insecureTls)} />
</span>
</label>
</div>
</Modal>
)
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<label className="block">
<span className="mb-1.5 flex items-center gap-2">
<span className="text-body font-medium text-content">{label}</span>
{!required && <Badge tone="neutral">{t('docsync.connect.optional')}</Badge>}
</span>
{children}
{hint && <span className="mt-1.5 block text-caption text-content-muted">{hint}</span>}
</label>
)
}
/** 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 (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
<Loader2 size={14} className="animate-spin" />
{t('docsync.connect.testing')}
</span>
)
}
// 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 (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
<AlertTriangle size={14} />
<span className="truncate">{t(`docsync.error.${failure}`)}</span>
</span>
)
}
if (!verdict) return <span />
if (verdict.connected) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-success">
<Check size={14} strokeWidth={2.5} />
<span className="truncate">
{verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')}
</span>
</span>
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
)
}
@@ -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<string, string>
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(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
{...props}
/>,
)
}
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(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'false')
rerender(
<DocSyncFlow
direction="pull"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
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(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
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(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
disabled
onChange={onChange}
/>,
)
expect(screen.getByText(S['docsync.flow.summary.both'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.flow.summaryEditable.both'])).not.toBeInTheDocument()
})
})
@@ -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 (
<section className="rounded-2xl border border-edge bg-surface-secondary p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-5">
<End
name={t('docsync.flow.trek')}
count={holdings.inTrek}
glyph={<TrekIcon className="h-6 w-6 text-content" />}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<Lane
active={pushOn}
running={running}
disabled={disabled}
caption={t('docsync.flow.toProvider')}
onClick={() => toggle('push')}
>
<ArrowRight size={13} strokeWidth={2.75} />
</Lane>
<Lane
active={pullOn}
running={running}
reverse
disabled={disabled}
caption={t('docsync.flow.toTrek')}
onClick={() => toggle('pull')}
>
<ArrowLeft size={13} strokeWidth={2.75} />
</Lane>
{/* 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. */}
<p className="truncate px-1 text-center text-caption text-content-muted">{hint}</p>
</div>
<End
name={providerName}
count={holdings.atProvider}
glyph={Icon ? <Icon className="h-6 w-6 text-content" /> : null}
/>
</div>
</section>
)
}
/**
* 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 (
<div
className="flex w-24 shrink-0 flex-col items-center gap-2 rounded-2xl border border-edge bg-surface px-3 py-4 shadow-card sm:w-32"
>
<span className="grid h-10 w-10 place-items-center rounded-xl bg-surface-secondary">{glyph}</span>
<span className="flex flex-col items-center gap-1">
<span className="text-title font-semibold tabular-nums leading-none text-content">{count}</span>
<span className="text-caption uppercase leading-none tracking-wide text-content-faint">
{t('docsync.flow.documents')}
</span>
</span>
<Badge tone="neutral" title={name}>
<span className="max-w-[6.5rem] truncate">{name}</span>
</Badge>
</div>
)
}
/**
* 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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={[
// No border at all, not a transparent one: a border box across the
// diagonals still renders a hairline where it meets the corner radius,
// and that line reads as a broken frame rather than as a hatch.
'group relative flex h-12 items-center gap-3 overflow-hidden rounded-xl px-3 text-caption transition-all duration-200',
reverse ? 'flex-row-reverse' : '',
active
? 'bg-accent-subtle text-accent-on'
: 'trek-docsync-lane-off bg-surface text-content-faint',
disabled ? 'cursor-default' : active ? 'hover:brightness-[0.98]' : 'hover:text-content-muted',
].join(' ')}
>
<span
className={[
'relative grid h-7 w-7 shrink-0 place-items-center rounded-lg transition-transform duration-200',
active ? 'bg-surface shadow-sm' : 'bg-surface-secondary',
disabled ? '' : reverse ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5',
].join(' ')}
>
{children}
</span>
{/* Explicitly aligned to the arrow's side: the dialog inherits a centred
text-align, which would otherwise float the label away from it. */}
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
{caption}
</span>
{/* Only while a run is in flight. An idle lane needs no track (that it is
on or off is already in its fill), and a permanent line just asks the
reader what it means. */}
{moving && (
<span
aria-hidden
className={`trek-docsync-lane-run pointer-events-none absolute inset-x-3 bottom-1.5 h-0.5 rounded-full ${
reverse ? 'trek-docsync-lane-run-rev' : ''
}`}
/>
)}
</button>
)
}
@@ -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<string, string>)[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<typeof import('../../../api/client')>()
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> = {}): 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<string, number>
}): 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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(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<DocSyncProvider[]>()
providers.mockReturnValue(gate.promise)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// 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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
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(<DocSyncPanel tripId={7} tripTitle="Iceland 2026" canManage onClose={onClose} />)
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)
})
})
@@ -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<number | null>(null)
const [connecting, setConnecting] = useState<DocSyncProvider | null>(null)
const [scopeFor, setScopeFor] = useState<string | null>(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 (
<>
<Modal
isOpen
onClose={onClose}
size="4xl"
title={
<span className="flex flex-col">
<span>{t('docsync.title')}</span>
{tripTitle && (
<span className="mt-0.5 truncate text-caption font-normal text-content-muted">{tripTitle}</span>
)}
</span>
}
>
{sync.loading ? (
<div className="grid place-items-center py-20">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
) : 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.
<Empty />
) : (
<div className="grid gap-5 md:grid-cols-[17rem_minmax(0,1fr)] md:gap-6">
<Sidebar
links={sync.links}
providers={sync.providers}
available={available}
activeId={active?.id ?? null}
canManage={canManage}
onSelect={setSelected}
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
<div className="min-w-0 md:border-l md:border-edge-faint md:pl-6">
{active ? (
<div className="space-y-5">
<DocSyncBinding
link={active}
providerName={storeName(active, sync.providers)}
sync={sync}
canManage={canManage}
/>
{attention > 0 && <Attention counts={sync.itemCounts} tripId={tripId} sync={sync} />}
</div>
) : (
<NothingBound canManage={canManage} />
)}
</div>
</div>
)}
</Modal>
{connecting && (
<DocSyncConnectModal
provider={connecting}
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
/>
)}
{scopeFor && sync.connectionFor(scopeFor) && (
<DocSyncScopeModal
connection={sync.connectionFor(scopeFor)!}
providerName={sync.providers.find(p => 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 (
<nav className="space-y-5">
{links.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{t('docsync.sidebar.connected')}
</h4>
<ul className="space-y-1.5">
{links.map(link => (
<li key={link.id}>
<StoreButton
providerId={link.providerId}
title={storeName(link, providers)}
subtitle={link.remoteLabel || link.remoteRootPath || link.scopeKey}
state={link.lastSyncState}
active={link.id === activeId}
onClick={() => onSelect(link.id)}
/>
</li>
))}
</ul>
</div>
)}
{canManage && available.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}
</h4>
<ul className="space-y-1.5">
{available.map(p => (
<li key={p.id}>
<StoreButton
providerId={p.id}
title={p.name}
subtitle={t(`docsync.model.${p.id}`)}
onClick={() => onAdd(p)}
addable
/>
</li>
))}
</ul>
</div>
)}
</nav>
)
}
/**
* 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 (
<button
type="button"
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={[
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors',
active
? 'border-transparent bg-accent-subtle'
: 'border-edge bg-surface hover:bg-surface-hover',
].join(' ')}
>
<span
className={[
'grid h-8 w-8 shrink-0 place-items-center rounded-lg',
active ? 'bg-surface' : 'bg-surface-secondary',
].join(' ')}
>
{Icon ? <Icon className={`h-4 w-4 ${active ? 'text-accent-on' : 'text-content'}`} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-body ${active ? 'font-medium text-accent-on' : 'text-content'}`}>
{title}
</span>
<span className="mt-0.5 block truncate text-caption text-content-muted">{subtitle}</span>
</span>
{addable ? (
<Plus size={14} className="shrink-0 text-content-faint transition-transform group-hover:scale-110" />
) : state ? (
<StateBadge state={state} compact />
) : null}
</button>
)
}
/** What a person still has to decide about, named rather than counted in a chip. */
function Attention({
counts,
tripId,
sync,
}: {
counts: Record<string, number>
tripId: number | string
sync: ReturnType<typeof useDocSync>
}) {
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 (
<section className="rounded-xl border border-edge bg-surface">
<h4 className="flex items-center gap-2 border-b border-edge-faint px-4 py-2.5 text-body font-medium text-content">
<AlertCircle size={14} className="text-warning" />
{t('docsync.issues.title')}
</h4>
<ul className="divide-y divide-edge-faint">
{rows.map(k => (
<li key={k} className="px-4 py-2.5">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0">
<span className="block text-body text-content">{t(`docsync.state.${k}`)}</span>
<span className="mt-0.5 block text-caption text-content-muted">{t(`docsync.issues.${k}`)}</span>
</span>
{/* 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' ? (
<button
type="button"
onClick={() => setShowConflicts(v => !v)}
className="shrink-0 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
>
{showConflicts ? t('common.close') : t('docsync.conflict.resolve', { count: counts[k] })}
</button>
) : (
<span className="shrink-0 text-body font-medium tabular-nums text-content">{counts[k]}</span>
)}
</div>
{k === 'conflict' && showConflicts && (
<ul className="mt-3 space-y-2">
{conflicts.items === null && (
<li className="flex justify-center py-2">
<Loader2 size={14} className="animate-spin text-content-muted" />
</li>
)}
{conflicts.items?.map(item => (
<li key={item.id} className="rounded-lg bg-surface-secondary px-3 py-2.5">
<p className="truncate text-body text-content" title={item.name}>{item.name}</p>
<p className="mt-0.5 text-caption text-content-muted">{t('docsync.conflict.title')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(['trek', 'provider', 'both'] as const).map(keep => (
<button
key={keep}
type="button"
disabled={conflicts.working === item.id}
onClick={() => void conflicts.resolve(item.id, keep)}
className="rounded-lg border border-edge px-2.5 py-1 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t(`docsync.conflict.${keep === 'trek' ? 'keepTrek' : keep === 'provider' ? 'keepProvider' : 'keepBoth'}`)}
</button>
))}
</div>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)
}
/** The detail column before anything is bound: an invitation, not a blank. */
function NothingBound({ canManage }: { canManage: boolean }) {
const { t } = useTranslation()
return (
<div className="grid h-full min-h-[16rem] place-items-center rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<div>
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<ArrowRight size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.empty.title')}</p>
<p className="mx-auto mt-1 max-w-xs text-caption text-content-muted">
{canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
</p>
</div>
</div>
)
}
function Empty() {
const { t } = useTranslation()
return (
<div className="rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<Check size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mx-auto mt-1 max-w-sm text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
/**
* 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}`
}
@@ -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<string, string>
const listScopes = vi.fn()
const createScope = vi.fn()
const createLink = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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 (
<DocSyncScopeModal
connection={connection}
providerName={providerName}
suggestedName={suggestedName}
sync={sync}
onClose={onClose}
onBound={onBound}
/>
)
}
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(<Harness />)
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(<Harness />)
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(<Harness />)
// 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(<Harness />)
expect(await screen.findByText(S['docsync.noFolders'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-005: names the store in the question it asks', async () => {
render(<Harness providerName="Paperless" />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness suggestedName="trek-paris-42" />)
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(<Harness suggestedName="Paris 2026" />)
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(<Harness suggestedName="Paris 2026" />)
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(<Harness suggestedName="Paris 2026" />)
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(<Harness suggestedName="" />)
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(<Harness suggestedName="trek-paris-42" />)
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(<Harness />)
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(<Harness />)
expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
})
})
@@ -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<typeof useDocSync>
onClose: () => void
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [newName, setNewName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(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 (
<Modal isOpen onClose={onClose} size="md" title={t('docsync.scope.title', { provider: providerName })}>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
<div className="flex gap-2">
<input
value={newName}
onChange={e => 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"
/>
<button
type="button"
onClick={() => void createAndBind()}
disabled={!newName.trim() || working !== null}
className="flex shrink-0 items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
{t('docsync.scope.createAction')}
</button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('docsync.scope.pickTitle')}</span>
{scopes && scopes.length > 6 && (
<span className="relative">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={e => 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"
/>
</span>
)}
</div>
{sync.error && (
<p className="mb-2 rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{scopes === null ? (
<div className="grid place-items-center rounded-xl border border-edge py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
) : error ? (
<p className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${error}`)}
</p>
) : shown.length === 0 ? (
<p className="rounded-xl border border-dashed border-edge px-3 py-6 text-center text-caption text-content-muted">
{query ? t('docsync.scope.noMatch') : t('docsync.noFolders')}
</p>
) : (
<ul className="max-h-64 divide-y divide-edge-faint overflow-y-auto rounded-xl border border-edge">
{shown.map(s => (
<li key={s.scopeKey}>
<button
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<span className="min-w-0">
<span className="block truncate text-body text-content">{s.label}</span>
{s.remoteRootPath && (
<span className="block truncate text-caption text-content-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey && <Loader2 size={14} className="shrink-0 animate-spin text-content-faint" />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
)
}
@@ -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<typeof useDocSync>, open: boolean) {
const [items, setItems] = useState<ConflictItem[] | null>(null)
const [working, setWorking] = useState<number | null>(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 }
}
@@ -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<string, string>
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<boolean>
}
export function useConnectForm(
provider: DocSyncProvider,
existing: DocSyncConnection | null,
sync: ReturnType<typeof useDocSync>,
): ConnectForm {
const [values, setValues] = useState<Record<string, string>>({})
// 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<boolean | null>(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),
}
}
@@ -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<unknown> => [])
const statusApi = vi.fn(async (_tripId: number | string): Promise<unknown> => ({}))
const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinksApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({}))
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
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<string, unknown> = {}) => ({
id: 'nextcloud',
name: 'Nextcloud',
description: null,
icon: 'cloud',
available: true,
fields: [],
...overrides,
})
const connection = (overrides: Record<string, unknown> = {}) => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
const link = (overrides: Record<string, unknown> = {}) => ({
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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<unknown>()
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<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
seenLoading.length = 0
let pending!: Promise<boolean>
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<unknown>()
updateLinkApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<boolean>
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<unknown>()
testConnectionApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<unknown>
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.<code>, 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)
})
})
@@ -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<string, string>
secrets: Record<string, string>
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<DocSyncLink, 'providerOff' | 'lastSyncState' | 'lastSyncError'>,
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<DocSyncProvider[]>([])
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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<string, number>; 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<string, string>, 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<string, string>, 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<DocSyncScope | null> => {
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<string, unknown>) => {
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<string, unknown>) => {
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.<code>`, 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.<value>`, 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'
}
@@ -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<string, unknown>) => void>()
vi.mock('../../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => 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))
})
})
@@ -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
}
@@ -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<number | null>(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,
}
}
@@ -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(<MapView places={[buildMapPlace({ id: 27, lat: 48, lng: 2, image_url: '/uploads/places/wide.jpg' })]} />)
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(<MapView places={[place]} />)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
rerender(<MapView places={[{ ...place, image_url: null }]} />)
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.
+4 -4
View File
@@ -195,7 +195,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
">
<img src="${escapeHtml(place.image_url)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(place.image_url)}
</div>
${badgeHtml}
</div>`,
@@ -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<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(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
+51 -2
View File
@@ -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(<MapViewGL places={[buildMapPlace({ id: 14, lat: 48.14, lng: 2.14, image_url: '/uploads/places/wide.jpg' })]} fitKey={1} />)
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(<MapViewGL places={[place]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
rerender(<MapViewGL places={[{ ...place, image_url: null }]} fitKey={1} />)
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<typeof photoService.getCached>)
: 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(<MapViewGL places={[place]} fitKey={1} />)
await flushFrames()
rerender(<MapViewGL places={[{ ...place, image_url: second }]} fitKey={1} />)
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 () => {
+4 -5
View File
@@ -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;
">
<img src="${escapeHtml(photoUrl)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(photoUrl)}
</div>
${badgeHtml}
`
@@ -1720,7 +1719,7 @@ export function MapViewGL({
// simultaneous thumb arrivals into one re-render.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(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
@@ -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' }]))
})
})
+44 -8
View File
@@ -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<PhotoSource & { id: number }>): 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 `<img src="${escapeHtml(url)}" alt="" style="display:block;width:100%;height:100%;border-radius:50%;object-fit:cover;" />`
}
@@ -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>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | 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<string, unknown>) => {
// '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)
}
@@ -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(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} roadtripActive />);
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(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
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' });
@@ -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, string | number>) => 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 && <p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.endIsLeave')}</p>}
</div>
</div>
{hasTimeError && (
@@ -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(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} onEditStay={onEditStay} />)
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(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} onEditStay={onEditStay} />)
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 }),
@@ -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 ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
</span>
)
@@ -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
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
</span>
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
@@ -844,7 +855,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : 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. */}
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
@@ -1099,7 +1111,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : 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 (
<Tooltip label={label}>
<span
@@ -1348,8 +1359,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
const service = isServiceStopType(stop.stopType)
if (!service) counted += 1
// Every finding at this index is read on its own. Taking the first match let an
// overnight crossing swallow the "you arrive late" flag without a trace.
const marks = day.schedule.warnings.filter(w => 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
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => 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}
/>
@@ -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<typeof vi.fn>
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'))
})
})
@@ -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 <LeaveTime stop={stop} leave={leave} until={leave.until} is12h={is12h} onClose={onClose} />
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 ? (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{times.leave}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
) : null}
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.leaveCarry} /> : null}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESETS.map(value => (
@@ -237,3 +225,106 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
</Modal>
)
}
/**
* 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 (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
<span className="text-center text-title font-semibold tabular-nums tracking-tight text-content">
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
</span>
{leave.missedBy !== null ? (
<p className="flex items-center justify-center gap-1.5 text-center text-body font-medium text-warning">
<AlertTriangle size={14} className="shrink-0" aria-hidden />
{t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })}
</p>
) : (
<p className="text-center text-body text-content-secondary">
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
</p>
)}
{/* 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 ? <ArriveLeave arrive={arrive} leave={departure} /> : null}
</div>
<div className="mt-6 flex items-center gap-2">
{leave.remove ? (
<button
type="button"
disabled={leave.removing}
onClick={() => void leave.remove?.()}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.clearLeave')}
</button>
) : null}
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.close')}
</button>
</div>
</Modal>
)
}
/** 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 (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{leave}
{carry > 0 ? (
<span className="ms-0.5">
{`+${carry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
)
}
@@ -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(<VisitControls stay={{ minutes: 240, until: '14:00', onEdit: vi.fn() }} />)
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(<VisitControls stay={{ minutes: null, until: '14:00' }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
@@ -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 (
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{endDay && <EndDayControl {...endDay} />}
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
{' '}<span className="flex min-w-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? (until ? null : '+') : formatDurationShort(stay.minutes * 60)}
{until ? <>{' '}<span className="truncate text-content-faint">{until}</span></> : null}
{stay.onEdit && <Pencil size={12} className="shrink-0 text-content-faint" aria-hidden />}
</span>
</button>}
</div>
@@ -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> = {}): 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', () => {
@@ -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<ScheduleWarning['code'], number> = {
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,
}
}
@@ -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> = {}): 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)
})
})
@@ -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<RoadtripStop, 'dwellMinutes' | 'leaveAt'>,
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<RoadtripDay, 'schedule'>, 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 }
}
@@ -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<typeof window.__addToast>
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<typeof vi.fn>
let updatePlace: ReturnType<typeof vi.fn>
let refreshDays: ReturnType<typeof vi.fn>
let addToast: ReturnType<typeof vi.fn<AddToast>>
beforeEach(() => {
resetAllStores()
setAssignmentTimes = vi.fn(async () => undefined)
updatePlace = vi.fn(async () => undefined)
refreshDays = vi.fn(async () => undefined)
addToast = vi.fn<AddToast>(() => 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'))
})
})
@@ -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<void>
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,
}
}
@@ -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> = {}): 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)),
}))
@@ -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,
@@ -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<string, unknown> = {}): SVGElement {
const { container } = render(<Icon {...props} />)
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(<Icon />)
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(<TrekIcon />)
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(<TrekIcon className="h-6 w-6 text-content" />)
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(<TrekIcon data-testid="trek-icon" role="img" />)
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(<TrekIcon />)
expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true')
const labelled = render(<TrekIcon aria-hidden={false} aria-label="TREK" />)
const svg = labelled.container.querySelector('svg')!
expect(svg.getAttribute('aria-hidden')).toBe('false')
expect(svg.getAttribute('aria-label')).toBe('TREK')
})
})
@@ -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 (
<svg {...frame(size, className)}>
<path
d="M470 18c-38 62-104 104-179 149-63 38-119 77-146 135-25 55-17 119 8 174l-49 76"
stroke="currentColor"
strokeWidth="46"
strokeLinecap="round"
/>
<path
d="M470 18c14 96 2 195-45 273-46 76-124 129-217 146-25-55-33-119-8-174 27-58 83-97 146-135 45-27 88-54 124-86z"
fill="currentColor"
/>
<path
d="M52 214c-24 47-30 106-10 158 15 39 44 70 79 90"
stroke="currentColor"
strokeWidth="44"
strokeLinecap="round"
/>
<path
d="M52 214c33 36 66 76 82 122 12 35 12 73-3 106"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
/>
</svg>
)
}
/** Papra: a sheet with a folded corner and its lines of text. */
export function PapraIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<path
d="M104 24h164l140 140v264a56 56 0 0 1-56 56H104a56 56 0 0 1-56-56V80a56 56 0 0 1 56-56z"
stroke="currentColor"
strokeWidth="56"
strokeLinejoin="round"
/>
<path
d="M268 24v112a28 28 0 0 0 28 28h112"
stroke="currentColor"
strokeWidth="56"
strokeLinejoin="round"
/>
<path d="M140 216h32" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
<path d="M140 330h216" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
<path d="M140 442h216" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
</svg>
)
}
/** Nextcloud: the three rings. */
export function NextcloudIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<circle cx="256" cy="256" r="97" stroke="currentColor" strokeWidth="42" />
<circle cx="74" cy="256" r="56" stroke="currentColor" strokeWidth="38" />
<circle cx="438" cy="256" r="56" stroke="currentColor" strokeWidth="38" />
</svg>
)
}
/** OpenCloud: the open hexagon with the junction inside it. */
export function OpenCloudIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<path d="M26 128 256 8l230 120v72L256 80 26 200z" fill="currentColor" />
<path d="M26 384 256 504l230-120v-72L256 432 26 312z" fill="currentColor" />
<path
d="M140 212v32l100 58v100l16 10 16-10V302l100-58v-32l-16-10-100 58-100-58z"
fill="currentColor"
/>
</svg>
)
}
/** Synology Drive: the wordmark's own glyph, as the addon list already draws it. */
export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg
width={className ? undefined : size}
height={className ? undefined : size}
className={className}
viewBox="0 0 24 24"
style={{ flexShrink: 0, display: 'block' }}
aria-hidden
>
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
</svg>
)
}
/** Keyed by the provider ids in `document_providers`. */
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
paperless: PaperlessIcon,
papra: PapraIcon,
nextcloud: NextcloudIcon,
opencloud: OpenCloudIcon,
synologydrive: SynologyDriveIcon,
}
+19
View File
@@ -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 <img> 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<SVGSVGElement>) {
return (
<svg viewBox="0 0 512 512" aria-hidden className={className} {...props}>
<g transform="translate(56,51) scale(0.267)">
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z" fill="currentColor" />
</g>
</svg>
)
}
+46
View File
@@ -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);
@@ -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<string, ComponentType<{ size?: number }>> = {
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 && <MProviderShelf options={documentProviderOptions} />}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<>
{COLLAB_SUB_FEATURES.map(feat => (
@@ -234,23 +249,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
<div key={addon.id}>
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
{/* 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 (
<MSubRow
key={provider.key}
providerIcon={ProviderIcon}
title={provider.label}
subtitle={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
)
})}
</>
)}
{addon.id === 'journey' && addon.enabled && <MProviderShelf options={providerOptions} />}
</div>
))}
</MAdminCard>
@@ -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 => (
<MSubRow
key={provider.key}
providerIcon={PROVIDER_ICONS[provider.key]}
title={provider.label}
subtitle={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
))}
</>
)
}
interface MSubRowProps {
icon?: typeof Luggage
providerIcon?: ComponentType<{ size?: number }>
@@ -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) */}
<div
className="relative flex-none px-[18px] py-4"
className="relative flex-none px-[18px] pb-4 pt-[14px]"
style={cover ? undefined : { background: 'linear-gradient(120deg,#2FA9A0,#3B8C7E)' }}
>
{cover && (
@@ -167,43 +168,42 @@ export default function MCollPlaceSheet({
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,.18),rgba(0,0,0,.44))]" />
</>
)}
{held.category?.name && (
<span className={HERO_CAT_CHIP}>
<MapPin size={9} strokeWidth={2.6} /> {held.category.name}
</span>
)}
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className={HERO_CLOSE}
>
<X size={15} strokeWidth={2.2} />
</button>
{canEdit && onUploadImage && (
<div className="absolute left-[14px] top-[14px] z-[1] flex gap-[6px]">
<button
type="button"
onClick={() => { if (!imgBusy) imageInputRef.current?.click() }}
aria-label={held.image_url ? t('places.changeImage') : t('places.uploadImage')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.4)] text-white" // theme-lint-disable
>
{imgBusy ? <Loader2 size={14} className="animate-spin" /> : <Camera size={14} />}
</button>
{held.image_url && !imgBusy && (
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.4)] text-white" // theme-lint-disable
>
<Trash2 size={14} />
</button>
{/* 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. */}
<div className="relative flex items-center gap-2">
{held.category?.name && (
<span className={HERO_CAT_CHIP}>
<MapPin size={9} strokeWidth={2.6} className="flex-none" />
<span className="truncate">{held.category.name}</span>
</span>
)}
{/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */}
<div className="-me-1 ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" className="hidden" onChange={handleImagePick} />
{held.image_url && !imgBusy && (
<button type="button" onClick={handleImageRemove} aria-label={t('places.removeImage')} className={HERO_BTN}>
<Trash2 size={15} strokeWidth={2.2} />
</button>
)}
<button
type="button"
onClick={() => { if (!imgBusy) imageInputRef.current?.click() }}
aria-label={held.image_url ? t('places.changeImage') : t('places.uploadImage')}
className={HERO_BTN}
>
{imgBusy ? <Loader2 size={15} strokeWidth={2.2} className="animate-spin" /> : <Camera size={15} strokeWidth={2.2} />}
</button>
</>
)}
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" className="hidden" onChange={handleImagePick} />
<button type="button" onClick={onClose} aria-label={t('common.close')} className={HERO_BTN}>
<X size={15} strokeWidth={2.2} />
</button>
</div>
)}
<div className="relative mt-[10px] text-[1.3125rem] font-extrabold text-white">{held.name}</div>
</div>
<div className="relative mt-1 text-[1.3125rem] font-extrabold text-white">{held.name}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
+26 -2
View File
@@ -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' && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-[7px]">
<PrimaryPill icon={<Upload size={13} strokeWidth={2.2} />} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} />
<DocSyncButton tripId={tripId} trip={trip} label={t('docsync.title')} onOpen={() => setOpenDocSyncSignal(s => s + 1)} />
<MIconBtn ariaLabel={t('files.trash')} onClick={() => setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Trash2 size={15} strokeWidth={2} />
</MIconBtn>
@@ -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 (
<MIconBtn ariaLabel={label} onClick={onOpen} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<FolderSync size={15} strokeWidth={2} />
</MIconBtn>
)
}
/** 40px list-density toggle on the transports/bookings headers. */
function CompactToggle({ active, onToggle, label }: { active: boolean; onToggle: () => void; label: string }) {
return (
@@ -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 && (
<RtLeaveTime name={stop.name} leave={leave} arrival={arrival} departure={located?.entry?.departure ?? null} onClose={shell.closeSheet} />
)}
{stop && !leave.until && (
<>
<FormSheetHeader
title={t('roadtrip.stay.add')}
@@ -293,3 +311,74 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
</MSheet>
)
}
/**
* 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 (
<>
<FormSheetHeader title={t('roadtrip.stop.stay')} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-1">
{name && <div className="truncate text-[0.875rem] font-semibold text-m-ink">{name}</div>}
<div className="mt-[18px] text-center text-[2.5rem] font-extrabold leading-none tabular-nums text-m-ink">
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
</div>
{/* 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 ? (
<div className="mt-[10px] flex items-center justify-center gap-1.5 text-center text-[0.8125rem] font-medium leading-snug text-[color:var(--m-st-pending)]">
<AlertTriangle size={14} strokeWidth={2} className="flex-none" aria-hidden="true" />
{t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })}
</div>
) : (
<div className="mt-[10px] text-center text-[0.8125rem] leading-snug text-m-muted">
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
</div>
)}
{arrival && !leave.dayEndsFirst && (
<div className={`mt-[18px] flex items-center justify-center gap-4 rounded-[16px] px-3 py-[11px] ${INNER_CLS}`}>
<span className="flex flex-col items-center gap-[2px]">
<span className={captionCls}>{t('roadtrip.stay.arrive')}</span>
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-muted">
{displayTime(arrival, locale, timeFormat)}
</span>
</span>
<ArrowRight size={14} strokeWidth={2} className="mt-[11px] flex-none text-m-faint" aria-hidden="true" />
<span className="flex flex-col items-center gap-[2px]">
<span className={captionCls}>{t('roadtrip.stay.leave')}</span>
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-ink">
{displayTime(departure ?? leave.until, locale, timeFormat)}
</span>
</span>
</div>
)}
</div>
{leave.remove && (
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={() => { void leave.remove?.() }}
disabled={leave.removing}
className="ml-auto inline-flex h-11 items-center rounded-full bg-m-act px-[18px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-40"
>
{t('roadtrip.stay.clearLeave')}
</button>
</div>
)}
</>
)
}
@@ -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 <AlertTriangle size={15} strokeWidth={2} />
if (w.code === 'late' || w.code === 'missedLeave') return <AlertTriangle size={15} strokeWidth={2} />
if (w.code === 'range') return <Fuel size={15} strokeWidth={2} />
return <Hourglass size={15} strokeWidth={2} />
}
@@ -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) {
<div className="mt-2 font-geist text-[0.65625rem] leading-snug text-m-faint">
{entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
</div>
{/* 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 && (
<div className="mt-1 font-geist text-[0.65625rem] leading-snug text-m-faint">
{t(stay.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', {
time: displayTime(stay.until, locale, timeFormat),
})}
</div>
)}
</div>
)}
{/* ── What this stop costs, and what the drive to it went past ── */}
{(!!stayMinutes || !!row.offRoadMeters || warnings.length > 0) && (
{(!!stayText || !!row.offRoadMeters || warnings.length > 0) && (
<div className="mt-2.5 flex flex-col gap-2">
{!!stayMinutes && (
{!!stayText && (
<Finding
icon={<Hourglass size={15} strokeWidth={2} />}
label={t('roadtrip.stop.stayShort')}
value={formatDurationShort(stayMinutes * 60)}
value={stayText}
/>
)}
{!!row.offRoadMeters && (
@@ -399,7 +418,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
<ActionTile
icon={<Clock size={16} strokeWidth={2} />}
label={t('roadtrip.stop.stayShort')}
value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')}
value={stayText || t('roadtrip.stay.none')}
onClick={canEditPlace ? editStay : undefined}
/>
</div>
@@ -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({
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('places.endTime')}</Eyebrow>
<CustomTimePicker value={endTime} onChange={v => onChange('end_time', v)} />
{endIsLeave && (
<div className="mt-[5px] font-geist text-[0.65625rem] leading-snug text-m-faint">{t('roadtrip.stop.endIsLeave')}</div>
)}
</div>
</div>
{hasTimeError && (
@@ -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]>): 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>): 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>): DocSyncConnection => ({
id: 5,
providerId: 'paperless',
baseUrl: 'https://paperless.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...over,
})
const link = (over: Partial<DocSyncLink> = {}): 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>): 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<ComponentProps<typeof MDocSyncSheet>> = {}) {
return render(
<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" canManage open onClose={onClose} {...over} />,
)
}
/** 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(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" canManage open={false} onClose={onClose} />)
rerender(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" canManage open onClose={onClose} />)
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()
})
})
@@ -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<View>('list')
const [selected, setSelected] = useState<number | null>(null)
const [pending, setPending] = useState<DocSyncProvider | null>(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 (
<>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('docsync.title')}>
<div className="flex-none px-[18px] pt-4">
<div className="flex items-center gap-2">
{view !== 'list' && (
<MIconBtn variant="neutral" size={34} onClick={() => setView('list')} ariaLabel={t('common.back')}>
<ChevronLeft size={16} strokeWidth={2.2} />
</MIconBtn>
)}
<div className="min-w-0 flex-1">
<TileHeader
icon={<FolderSync size={19} strokeWidth={1.8} />}
title={title}
sub={view === 'list' ? tripTitle : undefined}
onClose={onClose}
closeLabel={t('common.close')}
/>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{sync.loading ? (
<div className="flex justify-center py-12">
<Loader2 size={20} className="animate-spin text-m-faint" />
</div>
) : sync.providers.length === 0 && sync.links.length === 0 ? (
// Only with nothing bound, as on the desktop panel.
<Empty text={t('docsync.noProviders')} hint={t('docsync.noProvidersHint')} />
) : view === 'connect' && pending ? (
<ConnectView
provider={pending}
sync={sync}
onDone={() => setView('scope')}
/>
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
<ScopeView
connectionId={sync.connectionFor(pending.id)!.id}
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onBound={() => setView('list')}
/>
) : view === 'detail' && link ? (
<DetailView
tripId={tripId}
link={link}
providerName={storeName(link, sync.providers)}
sync={sync}
canManage={canManage}
onUnlink={() => setConfirmUnlink(true)}
/>
) : (
<ListView
links={sync.links}
providers={sync.providers}
available={available}
canManage={canManage}
onOpen={id => { setSelected(id); setView('detail') }}
onAdd={openStore}
/>
)}
</div>
</MSheet>
<MConfirmSheet
open={confirmUnlink}
onClose={() => 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 (
<div className="mt-3 flex flex-col gap-4">
{links.length > 0 && (
<section>
<SectionLabel>{t('docsync.sidebar.connected')}</SectionLabel>
<div className="flex flex-col gap-2">
{links.map(l => (
<StoreRow
key={l.id}
providerId={l.providerId}
title={storeName(l, providers)}
sub={l.remoteLabel || l.remoteRootPath || l.scopeKey}
state={l.lastSyncState}
onClick={() => onOpen(l.id)}
/>
))}
</div>
</section>
)}
{canManage && available.length > 0 && (
<section>
<SectionLabel>{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}</SectionLabel>
<div className="flex flex-col gap-2">
{available.map(p => (
<StoreRow
key={p.id}
providerId={p.id}
title={p.name}
sub={t(`docsync.model.${p.id}`)}
addable
onClick={() => onAdd(p)}
/>
))}
</div>
</section>
)}
{links.length === 0 && !canManage && (
<Empty text={t('docsync.empty.title')} hint={t('docsync.empty.hintMember')} />
)}
</div>
)
}
/** 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<typeof useDocSync>
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 (
<div className="mt-3 flex flex-col gap-3">
{/* 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. */}
<section className="rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop p-3">
<div className="flex items-stretch gap-2">
<EndBox glyph={<TrekIcon className="h-[18px] w-[18px]" />} count={holdings.inTrek} name={t('docsync.flow.trek')} />
<EndBox
glyph={ProviderIcon ? <ProviderIcon className="h-[18px] w-[18px]" /> : null}
count={holdings.atProvider}
name={providerName}
/>
</div>
<div className="mt-2 flex flex-col gap-2">
<LaneRow
active={pushOn}
label={t('docsync.flow.toProvider')}
icon={<ArrowRight size={13} strokeWidth={2.6} />}
disabled={!canManage}
onClick={() => toggle('push')}
/>
<LaneRow
active={pullOn}
label={t('docsync.flow.toTrek')}
icon={<ArrowLeft size={13} strokeWidth={2.6} />}
disabled={!canManage}
onClick={() => toggle('pull')}
/>
</div>
</section>
<Row label={t('docsync.binding.folder')} value={link.remoteLabel || link.remoteRootPath || link.scopeKey} />
<Row
label={t('docsync.binding.lastRun')}
value={Number.isNaN(ranAt) ? t('docsync.binding.neverRun') : relativeTime(ranAt, language)}
/>
{notice && (
<p className="rounded-2xl bg-[color:var(--m-st-warn-bg,var(--m-ic))] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-warn,var(--m-muted))]">
{notice}
</p>
)}
<MConflicts tripId={tripId} sync={sync} count={sync.itemCounts.conflict ?? 0} />
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl bg-m-act text-[0.8125rem] font-bold text-m-actfg disabled:opacity-60"
>
<RefreshCw size={15} strokeWidth={2.2} className={busy ? 'animate-spin' : ''} />
{busy ? t('docsync.syncing') : t('docsync.syncNow')}
</button>
{canManage && (
<>
<SettingRow label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<MToggle
checked={link.syncEnabled}
ariaLabel={t('docsync.syncEnabled')}
onChange={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</SettingRow>
<SettingRow label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<button
type="button"
onClick={() =>
void sync.updateLink(link.id, { deletePolicy: link.deletePolicy === 'unlink' ? 'trash' : 'unlink' })
}
className="rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-[0.6875rem] font-bold text-m-ink"
>
{link.deletePolicy === 'unlink' ? t('docsync.deleteUnlink') : t('docsync.deleteTrash')}
</button>
</SettingRow>
<SettingRow label={t('docsync.conflictPolicy')} hint={t('docsync.binding.conflictHint')}>
<button
type="button"
onClick={() =>
void sync.updateLink(link.id, { conflictPolicy: nextConflictPolicy(link.conflictPolicy) })
}
className="rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-[0.6875rem] font-bold text-m-ink"
>
{t(conflictPolicyKey(link.conflictPolicy))}
</button>
</SettingRow>
<button
type="button"
onClick={onUnlink}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]"
>
<Link2Off size={15} strokeWidth={2.2} />
{t('docsync.unlink')}
</button>
</>
)}
</div>
)
}
/**
* 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<typeof useDocSync>
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 (
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{form.fields.map(f => (
<label key={f.field_key} className="flex flex-col gap-[6px]">
<span className="font-geist text-[0.6875rem] font-bold text-m-muted">
{t(form.labelKey(f))}
{f.required && <span className="text-[color:var(--m-st-danger)]"> *</span>}
</span>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={form.valueOf(f.field_key)}
onChange={e => { form.setValue(f.field_key, e.target.value); setVerdict(null) }}
placeholder={form.placeholderOf(f)}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
className="h-11 rounded-2xl border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-3 text-[0.8125rem] text-m-ink outline-none"
/>
{form.hintKey(f) && (
<span className="font-geist text-[0.625rem] text-m-faint">{t(form.hintKey(f) as string)}</span>
)}
</label>
))}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px]">
<span className="min-w-0 flex-1">
<span className="block text-[0.78125rem] font-semibold text-m-ink">{t('docsync.allowInsecureTls')}</span>
<span className="mt-[2px] block font-geist text-[0.625rem] leading-snug text-m-faint">
{t('docsync.connect.insecureHint')}
</span>
</span>
<MToggle
checked={form.insecureTls}
ariaLabel={t('docsync.allowInsecureTls')}
onChange={form.setInsecureTls}
/>
</div>
{verdict && (
<p
className={`rounded-2xl px-3 py-2 font-geist text-[0.6875rem] ${
verdict.connected ? 'text-[color:var(--m-st-ok,var(--m-ink))]' : 'text-[color:var(--m-st-danger)]'
} bg-[color:var(--m-ic)]`}
>
{verdict.connected
? verdict.account
? t('docsync.connect.okAs', { account: verdict.account })
: t('docsync.connected')
: t(`docsync.error.${verdict.error ?? 'unknown'}`)}
</p>
)}
{sync.error && !verdict && (
<p className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
{t(`docsync.error.${sync.error}`)}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void form.probe().then(setVerdict)}
disabled={!form.complete || sync.busy === 'test'}
className="h-11 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] text-[0.8125rem] font-bold text-m-ink disabled:opacity-50"
>
{sync.busy === 'test' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('docsync.test')}
</button>
<button
type="button"
onClick={() => void form.save().then(ok => { if (ok) onDone() })}
disabled={!form.complete || sync.busy === 'save'}
className="h-11 flex-1 rounded-2xl bg-m-act text-[0.8125rem] font-bold text-m-actfg disabled:opacity-50"
>
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button>
</div>
</div>
)
}
/** Which folder, tag or space this trip gets. */
function ScopeView({
connectionId,
suggestedName,
sync,
onBound,
}: {
connectionId: number
suggestedName: string
sync: ReturnType<typeof useDocSync>
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<Awaited<ReturnType<typeof sync.loadScopes>>['scopes'] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(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 (
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<div className="flex gap-2">
<input
value={name}
onChange={e => 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"
/>
<button
type="button"
onClick={() => {
const trimmed = name.trim()
if (!trimmed) return
setWorking('__new__')
void sync.createScope(connectionId, trimmed)
.then(scope => { if (scope) return bind(scope) })
.finally(() => setWorking(null))
}}
disabled={!name.trim() || working !== null}
className="flex h-11 flex-none items-center gap-2 rounded-2xl bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={15} className="animate-spin" /> : <FolderPlus size={15} strokeWidth={2.2} />}
{t('docsync.scope.createAction')}
</button>
</div>
{sync.error && (
<p className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
{t(`docsync.error.${sync.error}`)}
</p>
)}
<SectionLabel>{t('docsync.scope.pickTitle')}</SectionLabel>
{scopes === null ? (
<div className="flex justify-center py-8"><Loader2 size={18} className="animate-spin text-m-faint" /></div>
) : error ? (
<p className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
{t(`docsync.error.${error}`)}
</p>
) : scopes.length === 0 ? (
<Empty text={t('docsync.noFolders')} />
) : (
<div className="flex flex-col gap-2">
{scopes.map(s => (
<button
key={s.scopeKey}
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex items-center gap-[10px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px] text-left disabled:opacity-50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{s.label}</span>
{s.remoteRootPath && (
<span className="mt-[2px] block truncate font-geist text-[0.625rem] text-m-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey
? <Loader2 size={14} className="flex-none animate-spin text-m-faint" />
: <ChevronRight size={15} strokeWidth={2} className="flex-none text-m-faint" />}
</button>
))}
</div>
)}
</div>
)
}
/** 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 (
<button
type="button"
onClick={onClick}
className="flex items-center gap-[10px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px] text-left"
>
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">
{Icon ? <Icon className="h-4 w-4" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{title}</span>
<span className="mt-[2px] block truncate font-geist text-[0.625rem] text-m-faint">{sub}</span>
</span>
{addable ? (
<Plus size={15} strokeWidth={2} className="flex-none text-m-faint" />
) : (
<span className="flex flex-none items-center gap-2">
{state && <StateDot state={state} />}
<ChevronRight size={15} strokeWidth={2} className="text-m-faint" />
</span>
)}
</button>
)
}
/** 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 (
<div className="flex min-w-0 flex-1 flex-col items-center gap-1 rounded-2xl bg-[color:var(--m-inner)] px-2 py-3">
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">
{glyph}
</span>
<span className="text-[1.25rem] font-bold leading-none tabular-nums text-m-ink">{count}</span>
<span className="font-geist text-[0.5625rem] uppercase leading-none tracking-wide text-m-faint">
{t('docsync.flow.documents')}
</span>
<span className="w-full truncate text-center font-geist text-[0.625rem] text-m-muted" title={name}>{name}</span>
</div>
)
}
/**
* 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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={`flex h-11 items-center gap-[10px] overflow-hidden rounded-2xl px-3 text-left ${
active ? 'bg-m-act text-m-actfg' : 'trek-docsync-lane-off-m bg-[color:var(--m-inner)] text-m-faint'
}`}
>
{/* Inverted on the active lane: --m-knob and --m-act are both white in
dark mode, so a knob-coloured tile there hid the arrow inside it. */}
<span
className={`flex h-7 w-7 flex-none items-center justify-center rounded-[9px] ${
active ? 'bg-[color:var(--m-actfg)] text-m-act' : 'bg-[color:var(--m-ic)] text-m-faint'
}`}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">{label}</span>
{active && <Check size={15} strokeWidth={2.4} className="flex-none" />}
</button>
)
}
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 (
<span
className="h-2 w-2 rounded-full"
style={{ background: tone }}
title={t(`docsync.linkState.${state}`)}
/>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="mb-2 font-geist text-[0.625rem] font-bold uppercase tracking-wide text-m-faint">{children}</div>
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px]">
<span className="font-geist text-[0.6875rem] text-m-muted">{label}</span>
<span className="min-w-0 truncate text-[0.78125rem] font-semibold text-m-ink" title={value}>{value}</span>
</div>
)
}
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px]">
<span className="min-w-0 flex-1">
<span className="block text-[0.78125rem] font-semibold text-m-ink">{label}</span>
{hint && <span className="mt-[2px] block font-geist text-[0.625rem] text-m-faint">{hint}</span>}
</span>
<span className="flex-none">{children}</span>
</div>
)
}
function Empty({ text, hint }: { text: string; hint?: string }) {
return (
<div className="py-10 text-center">
<div className="font-geist text-[0.78125rem] text-m-muted">{text}</div>
{hint && <div className="mx-auto mt-1 max-w-[16rem] font-geist text-[0.625rem] leading-snug text-m-faint">{hint}</div>}
</div>
)
}
/** 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<typeof useDocSync>
count: number
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const conflicts = useConflicts(tripId, sync, open)
if (count === 0) return null
return (
<section className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2.5">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="flex w-full items-center justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block font-geist text-[0.75rem] font-bold text-m-ink">{t('docsync.conflict.title')}</span>
<span className="mt-0.5 block font-geist text-[0.6875rem] text-m-muted">{t('docsync.issues.conflict')}</span>
</span>
<span className="shrink-0 font-geist text-[0.6875rem] font-bold text-m-ink">
{open ? t('common.close') : t('docsync.conflict.resolve', { count })}
</span>
</button>
{open && (
<ul className="mt-2.5 space-y-2">
{conflicts.items === null && (
<li className="flex justify-center py-2">
<Loader2 size={14} className="animate-spin text-m-muted" />
</li>
)}
{conflicts.items?.map(item => (
<li key={item.id} className="rounded-2xl bg-m-card px-3 py-2.5">
<p className="truncate font-geist text-[0.75rem] text-m-ink">{item.name}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(['trek', 'provider', 'both'] as const).map(keep => (
<button
key={keep}
type="button"
disabled={conflicts.working === item.id}
onClick={() => void conflicts.resolve(item.id, keep)}
className="rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-[0.6875rem] font-bold text-m-ink disabled:opacity-60"
>
{t(`docsync.conflict.${keep === 'trek' ? 'keepTrek' : keep === 'provider' ? 'keepProvider' : 'keepBoth'}`)}
</button>
))}
</div>
</li>
))}
</ul>
)}
</section>
)
}
@@ -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<FileFilterId>('all')
const [menuFileId, setMenuFileId] = useState<number | null>(null)
const [linkFileId, setLinkFileId] = useState<number | null>(null)
const currentUser = useAuthStore(st => st.user)
const [trashOpen, setTrashOpen] = useState(false)
const [docSyncOpen, setDocSyncOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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) {
/>
<MFileLinkSheet planner={planner} file={linkFile} onClose={() => setLinkFileId(null)} />
<MFileTrashSheet planner={planner} open={trashOpen} onClose={() => setTrashOpen(false)} />
<MDocSyncSheet
tripId={planner.tripId}
tripTitle={planner.trip?.title}
canManage={canManageDocSync(currentUser, planner.trip)}
open={docSyncOpen}
onClose={() => setDocSyncOpen(false)}
/>
{lightboxIndex != null && mediaFiles.length > 0 && (
<MFileLightbox
files={mediaFiles}
+1 -1
View File
@@ -1058,7 +1058,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
</LazyPanel>
)}
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { 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} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { 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} />
<TripFormModal
isOpen={showTripForm}
onClose={() => setShowTripForm(false)}
@@ -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<Record<string, unknown>>).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,
@@ -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<StayDraft | null>(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
+31 -2
View File
@@ -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)
})
})
+32
View File
@@ -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<Assignment> {
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<Assignment> {
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
},
}
+44
View File
@@ -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<boolean> {
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<boolean> {
requireOnline()
const links = (await docsyncApi.listLinks(tripId)) as unknown[]
return links.length > 0
},
}
+48
View File
@@ -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<string, boolean>
/** Whether the instance offers any provider. Null until asked. */
providers: boolean | null
refresh: (tripId: number | string, canManage: boolean) => Promise<void>
}
export const useDocSyncOfferStore = create<DocSyncOfferState>((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 } }))
}
},
}))
+26 -1
View File
@@ -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<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface AssignmentsSlice {
setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise<void>
setAssignmentTimes: (tripId: number | string, dayId: number, assignmentId: number, times: AssignmentTimes) => Promise<void>
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
@@ -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)))
+3 -2
View File
@@ -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;
}
+93
View File
@@ -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>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | 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<string, unknown>) => {
// '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])
}
+26
View File
@@ -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<typeof place>[]) =>
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])
})
})
+8 -11
View File
@@ -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<T extends {
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
* time inherits the time of the timed item before it, so untimed items stay where
* they were manually placed. Stable on the incoming order for ties.
*
* The rule itself is `chronoOrder` in @trek/shared, the same one the server uses when
* a start time is saved. The server feeds it the day's stops only, though. An untimed
* place behind a timed note or booking inherits that item's time here and the previous
* stop's time there, so on such a day the stored order and this one can differ.
*/
function applyChronoOrder(
items: MergedItem[],
dayId: number,
getDisplayTime: (r: any, dayId: number) => 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. */
+1
View File
@@ -325,6 +325,7 @@ export function buildShell(overrides: Partial<MTripShellApi> = {}): MTripShellAp
exportCostsCsvSignal: 0,
uploadFilesSignal: 0,
openFilesTrashSignal: 0,
openDocSyncSignal: 0,
};
return { ...base, ...overrides };
}
@@ -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(<MAdminAddonManager />);
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(<MAdminAddonManager />);
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(<><ToastContainer /><MAdminAddonManager /></>);
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(<><ToastContainer /><MAdminAddonManager /></>);
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(<MAdminAddonManager />);
@@ -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')
})
})
@@ -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<typeof vi.fn>
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()
})
})
})
@@ -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()
})
})
})
@@ -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<TripPlanner>)
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<TripPlanner>)
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 },
@@ -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(
<PlTimeFields
planner={buildPlanner({ activeTab: 'roadtrip' } as never)}
startTime="10:00"
endTime="14:00"
onChange={vi.fn()}
assignmentId={1}
dayAssignments={[SELF]}
hasTimeError={false}
/>,
)
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()
})
})
@@ -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 })] } });
+1
View File
@@ -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,
+137
View File
@@ -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.<label>`, the same way the photo provider form
* resolves `memories.<label>`.
*
* Nextcloud and OpenCloud are separate rows although one WebDAV adapter serves
* both: the fields differ (a Nextcloud login name and app password against a
* folder, an OpenCloud username and app token against a space), and an operator
* scanning the addon shelf should find the product they actually run.
*/
interface ProviderRow {
id: string;
name: string;
description: string;
icon: string;
sort_order: number;
}
interface ProviderFieldRow {
provider_id: string;
field_key: string;
label: string;
input_type: 'text' | 'url' | 'password' | 'checkbox';
placeholder: string | null;
hint: string | null;
required: 0 | 1;
secret: 0 | 1;
sort_order: number;
}
const PROVIDERS: ProviderRow[] = [
{
id: 'paperless',
name: 'Paperless-ngx',
description: 'Two-way document sync with a Paperless-ngx instance, scoped by tag',
icon: 'FileText',
sort_order: 0,
},
{
id: 'papra',
name: 'Papra',
description: 'Two-way document sync with a Papra organisation, scoped by tag',
icon: 'FileText',
sort_order: 1,
},
{
id: 'nextcloud',
name: 'Nextcloud',
description: 'Two-way document sync with a Nextcloud folder over WebDAV',
icon: 'Cloud',
sort_order: 2,
},
{
id: 'opencloud',
name: 'OpenCloud',
description: 'Two-way document sync with an OpenCloud space over WebDAV',
icon: 'Cloud',
sort_order: 3,
},
{
id: 'synologydrive',
name: 'Synology Drive',
description: 'Two-way document sync with a folder on a Synology NAS',
icon: 'HardDrive',
sort_order: 4,
},
];
/**
* `allow_insecure_tls` defaults to 0 on every provider, unlike the Synology
* photo provider, where skipping verification is the stored default. A LAN
* instance with a self-signed certificate is common enough to need the switch
* and not common enough to justify shipping it on.
*/
const FIELDS: ProviderFieldRow[] = [
// Paperless-ngx: token auth, tag scope.
{ provider_id: 'paperless', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'paperless', field_key: 'api_token', label: 'providerApiToken', input_type: 'password', placeholder: 'API token', hint: 'hintPaperlessToken', required: 1, secret: 1, sort_order: 1 },
{ provider_id: 'paperless', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 2 },
// Papra: bearer key, and an organisation the key must already belong to.
{ provider_id: 'papra', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://papra.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'papra', field_key: 'api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'ppapi_…', hint: 'hintPapraKey', required: 1, secret: 1, sort_order: 1 },
{ provider_id: 'papra', field_key: 'organization_id', label: 'providerOrganization', input_type: 'text', placeholder: 'org_…', hint: 'hintPapraOrg', required: 1, secret: 0, sort_order: 2 },
{ provider_id: 'papra', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 },
// Nextcloud: an app password, never the account password. The latter breaks
// the moment the account turns on 2FA or logs in through OIDC.
{ provider_id: 'nextcloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'nextcloud', field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'nextcloud', field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'nextcloud', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 3 },
{ provider_id: 'nextcloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 4 },
// OpenCloud: auth-app token, spaces instead of folders.
{ provider_id: 'opencloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://opencloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'opencloud', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: null, required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'opencloud', field_key: 'app_token', label: 'providerAppToken', input_type: 'password', placeholder: 'app token', hint: 'hintOpenCloudToken', required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'opencloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 },
// Synology: DSM has no app tokens at all, so this is a real account. The OTP
// is used once to obtain a device token, which is what gets stored.
{ provider_id: 'synologydrive', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'synologydrive', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintSynologyUser', required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'synologydrive', field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'synologydrive', field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: 0, secret: 1, sort_order: 3 },
{ provider_id: 'synologydrive', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/trek', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 4 },
{ provider_id: 'synologydrive', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 5 },
];
export function seedDocumentProviders(db: Database.Database): void {
const insertProvider = db.prepare(
'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)',
);
for (const p of PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order);
const insertField = db.prepare(
`INSERT OR IGNORE INTO document_provider_fields
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const f of FIELDS) {
insertField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order);
}
}
export const DOCUMENT_PROVIDER_SEED_IDS = PROVIDERS.map((p) => p.id);
+197
View File
@@ -1,6 +1,7 @@
import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import { readEnv } from '../app-config';
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
import { seedDocumentProviders } from './document-provider-seed';
import Database from 'better-sqlite3';
import fs from 'fs';
@@ -4949,6 +4950,202 @@ function runMigrations(db: Database.Database): void {
'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)',
);
},
/*
* Document providers, part 1 of 3: the registry (#214).
*
* Deliberately its own pair of tables rather than a `kind` column on
* `photo_providers`. The client filters on `type === 'photo_provider'`, the
* Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no
* WHERE clause, and migrations are append-only. Reusing those tables would
* change the behaviour of three existing paths, which is exactly what the
* no-breaking-changes rule forbids. The field columns follow
* `photo_provider_fields` except for `settings_key` and `payload_key`.
* Those map a photo field onto a settings key and a request key; a document
* field is stored under its own `field_key` in one JSON column, and the
* connect form takes its fields from /docsync/providers rather than from
* the generic settings form, so nothing would ever read them.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'FileText',
enabled INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS document_provider_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
UNIQUE(provider_id, field_key)
);
`);
seedDocumentProviders(db);
},
/*
* Document providers, part 2 of 3: the connection, and the trip binding.
*
* The connection carries a `trip_id`, and that is the one place this design
* departs from every integration already in the repo. Immich, Synology
* Photos, AirTrail and Dawarich all hang off a user, and even
* `trip_album_links` carries a `user_id`; photos become visible to the rest
* of a trip only through an opt-in `shared` flag. None of that can satisfy
* "everyone on the trip sees the same documents": it would make a
* document's visibility depend on whose credentials fetched it. So the trip
* admin binds the trip once, the server talks to the provider under that
* single identity, and TREK's own membership decides who sees what.
*
* `owner_user_id` stays separate from `trip_id` so the credential holder is
* always explicit: when that person leaves the trip the binding goes to
* `orphaned` rather than silently continuing to use an ex-member's token.
*
* Secrets live in one encrypted JSON blob instead of per-provider columns:
* a sixth provider then needs no migration, and the key rotation in
* scripts/migrate-encryption.ts stays one line instead of a field list that
* someone will forget, and a forgotten column does not survive a rotation.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
base_url TEXT NOT NULL,
secrets TEXT,
settings TEXT NOT NULL DEFAULT '{}',
allow_insecure_tls INTEGER NOT NULL DEFAULT 0,
capabilities TEXT,
last_probe_at TEXT,
last_probe_state TEXT NOT NULL DEFAULT 'never',
last_probe_error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, provider_id)
);
CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id);
CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id);
CREATE TABLE IF NOT EXISTS trip_document_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE,
provider_id TEXT NOT NULL,
remote_scope_key TEXT NOT NULL,
remote_root_id TEXT,
remote_root_path TEXT,
remote_label TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT 'both',
delete_policy TEXT NOT NULL DEFAULT 'unlink',
conflict_policy TEXT NOT NULL DEFAULT 'manual',
sync_enabled INTEGER NOT NULL DEFAULT 1,
webhook_token TEXT,
webhook_secret TEXT,
webhook_subscription_id TEXT,
remote_cursor TEXT,
last_sync_at TEXT,
last_sync_state TEXT NOT NULL DEFAULT 'never',
last_sync_error TEXT,
failure_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, connection_id, remote_scope_key)
);
CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token
ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL;
`);
},
/*
* Document providers, part 3 of 3: the pairing and its sync state.
*
* `content_sha256` and `pushed_sha256` are two columns on purpose. The
* first is the bytes both sides last agreed on, the second is what TREK
* itself last uploaded. Collapsing them into one is precisely the mistake
* that builds an echo loop: a webhook fires for TREK's own write, the core
* cannot tell it from a stranger's edit, and the file bounces.
*
* `remote_missing_at` records that something vanished upstream instead of
* acting on it, the rule Dawarich already follows with `source_missing_at`.
* An unmounted share answers with an empty listing, and reading that as
* "everything was deleted" would empty a trip.
*
* `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is
* permanently deleted in TREK, so the next run does not cheerfully download
* it again.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_sync_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL,
trek_doc_uid TEXT NOT NULL,
remote_id TEXT,
remote_name TEXT,
remote_version TEXT,
remote_size INTEGER,
remote_modified_at TEXT,
content_sha256 TEXT,
pushed_sha256 TEXT,
state TEXT NOT NULL DEFAULT 'pending',
error_code TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
remote_missing_at TEXT,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
synced_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote
ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file
ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid
ON document_sync_items(link_id, trek_doc_uid);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at);
`);
},
/*
* Document providers: when TREK itself put a provider copy in the bin.
*
* A file deleted in TREK under the `trash` policy takes its provider copy
* with it. Taken back out of TREK's trash, it has to go up again; a copy
* somebody else deleted in the meantime must not. Both leave the same gap
* in a listing, so the difference is written down when TREK acts rather
* than guessed at later.
*
* No backfill from the binding's current policy: that policy may not be
* the one the deletion ran under, and reading it back is the retroactive
* mistake this column exists to avoid. A row left NULL is treated like a
* copy somebody else deleted, which flags it instead of uploading it.
*/
() => {
const hasColumn = db
.prepare("SELECT 1 FROM pragma_table_info('document_sync_items') WHERE name = 'remote_trashed_at'")
.get();
if (!hasColumn) db.exec('ALTER TABLE document_sync_items ADD COLUMN remote_trashed_at TEXT');
},
];
if (currentVersion < migrations.length) {
+8
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { readEnv } from '../app-config';
import { seedDocumentProviders } from './document-provider-seed';
// bcrypt cost factor for the seeded admin password — kept in sync with authService.
const BCRYPT_COST = 12;
@@ -164,6 +165,13 @@ function seedAddons(db: Database.Database): void {
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
}
// Document providers live in their own pair of tables (see the migration
// for why they are not a `kind` column on photo_providers). Seeded from the
// same helper the migration uses, so a fresh install and an upgraded one
// agree.
seedDocumentProviders(db);
console.log('Default addons seeded');
} catch (err: unknown) {
console.error('Error seeding addons:', err instanceof Error ? err.message : err);
+50 -2
View File
@@ -598,6 +598,28 @@ export class AdminService {
fieldsByProvider.set(field.provider_id, arr);
}
// Document providers are the Documents addon's shelf rows, the same way
// photo providers are Journey's. Hidden on a managed instance for the same
// reason: Paperless, Papra, Nextcloud, OpenCloud and a Synology NAS are all
// servers the admin runs at home, and a hosted TREK has no route to one.
//
// They carry no `config` routes, unlike photo providers: the credentials do
// not belong to a user here but to a trip, so they are entered in the trip
// rather than in settings. The admin decides only whether a provider may be
// offered at all.
const docProviders = this.db.all<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>(`
SELECT id, name, description, icon, enabled, sort_order
FROM document_providers
ORDER BY sort_order, id
`).filter(() => !readEnv().managed.enabled);
return [
...addons.map((a) => ({
...a,
@@ -628,6 +650,17 @@ export class AdminService {
})),
sort_order: p.sort_order,
})),
...docProviders.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
type: 'document_provider',
icon: p.icon,
enabled: !!p.enabled,
config: {},
fields: [],
sort_order: p.sort_order,
})),
];
}
@@ -635,7 +668,8 @@ export class AdminService {
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
const docProvider = this.db.get<ProviderRow>('SELECT * FROM document_providers WHERE id = ?', id);
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
// The whole addon, not just its config: on a centrally administered install
// the operator owns the endpoint, the model and the per-document cost, so
@@ -652,6 +686,13 @@ export class AdminService {
return { error: 'Enable the Journey addon first', status: 409 };
}
// Same rule one shelf down: a document provider only exists to serve the
// file manager, so switching one on under a disabled Documents addon would
// advertise a sync nothing can reach.
if (docProvider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) {
return { error: 'Enable the Documents addon first', status: 409 };
}
this.db.transaction(() => {
if (addon) {
if (data.enabled !== undefined) {
@@ -660,6 +701,10 @@ export class AdminService {
// resurface the moment journey returns, which nobody switched on.
if (id === ADDON_IDS.JOURNEY && !data.enabled)
this.db.run('UPDATE photo_providers SET enabled = 0');
// Documents off takes its providers with it, for the reason above: a
// row left enabled would resurface the moment the addon returns.
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
this.db.run('UPDATE document_providers SET enabled = 0');
}
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
@@ -670,9 +715,12 @@ export class AdminService {
: data.config;
this.db.run('UPDATE addons SET config = ? WHERE id = ?', JSON.stringify(configToStore), id);
}
} else {
} else if (provider) {
if (data.enabled !== undefined)
this.db.run('UPDATE photo_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
} else {
if (data.enabled !== undefined)
this.db.run('UPDATE document_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
}
});
+2 -1
View File
@@ -45,6 +45,7 @@ import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
import { DawarichModule } from './integrations/dawarich.module';
import { DocSyncModule } from './doc-sync/doc-sync.module';
import { JourneyModule } from './journey/journey.module';
import { CollectionsModule } from './collections/collections.module';
import { ShareModule } from './share/share.module';
@@ -81,7 +82,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -167,8 +167,17 @@ export class AssignmentOpsController {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.updateTime(id, body.place_time, body.end_time);
const { assignment, reordered, vias } = this.assignments.updateTime(id, body.place_time, body.end_time);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
// The whole day when a start moved stops, or collaborators apply the one row
// they were sent to their old order and end up with a third one.
//
// Both to every socket, the writer's included, so the order and the vias pinned
// to it arrive together. The planner holds its vias in memory and would route the
// new anchors on the old order until its reload of the day came back, and a save
// replayed from the offline queue has no reload after it at all.
if (reordered) this.assignments.broadcast(tripId, 'assignment:reordered', reordered, undefined);
if (vias) this.assignments.broadcast(tripId, 'roadtripVia:changed', vias, undefined);
this.assignments.reconcile(tripId, socketId);
return { assignment };
}
@@ -130,12 +130,15 @@ export class AssignmentsMcp {
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) return errorResult('Assignment not found.');
const assignment = this.assignments.updateTime(
const { assignment, reordered, vias } = this.assignments.updateTime(
assignmentId,
place_time !== undefined ? place_time : existing.assignment_time,
end_time !== undefined ? end_time : existing.assignment_end_time
);
// Same three events as PUT /assignments/:id/time.
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
if (reordered) this.guards.safeBroadcast(tripId, 'assignment:reordered', reordered);
if (vias) this.guards.safeBroadcast(tripId, 'roadtripVia:changed', vias);
this.assignments.reconcile(tripId);
return ok({ assignment });
}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder, type AnchoredVia } from '@trek/shared/roadtrip';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -10,6 +11,39 @@ import { JourneyDomainService } from '../journey/journey-domain.service';
type Trip = TripAccess;
/** One stop of a day as the time sort reads it. */
interface DayStopRow {
id: number;
order_index: number;
effective_time: string | null;
located: number;
}
/**
* What saving a time changed besides the stop itself, so each caller can tell the
* trip. `reordered` carries the day's whole order and `vias` the day's re-pinned
* vias; both stay null when the save left every stop where it was.
*/
export interface AssignmentTimeUpdate {
assignment: ReturnType<AssignmentsService['getAssignmentWithPlace']>;
reordered: { dayId: number; orderedIds: number[] } | null;
vias: { dayId: number; vias: RoadtripVia[] } | null;
}
/**
* Where the time sort puts a value it cannot read as a clock time: after every real
* time, which is where the '99:99' sentinel has always put a time without a colon. A
* legacy "morning" keeps sorting where it did. The client reads such a value as no
* time at all; that difference is older than this rule and left alone.
*/
const UNREADABLE_TIME = 99 * 60 + 99;
function sortMinutes(time: string | null): number | null {
if (!time) return null;
const clock = /(?:^|T)(\d{1,2}):(\d{2})/.exec(time);
return clock ? Number(clock[1]) * 60 + Number(clock[2]) : UNREADABLE_TIME;
}
/**
* Assignments domain service — owns the day-assignment SQL (relocated from the
* legacy services/assignmentService.ts, then hardened: every multi-statement
@@ -206,48 +240,136 @@ export class AssignmentsService {
`, assignmentId);
}
updateTime(id: string | number, placeTime: unknown, endTime: unknown) {
this.dbs.transaction(() => {
/**
* Saves a visit's own start and end, and puts the day back in time order when the
* start changed. The rule is the planner's (`chronoOrder`): an untimed stop stays
* behind the stop it followed. This used to append every untimed stop after the
* timed ones, so one start time pulled a stop planned last to the top of the day.
*
* The rule is shared, what it reads is not. This sorts the day's stops alone, with a
* booked night timed by its check-in. The planner sorts day notes and bookings in
* between them and never draws the night's row. An untimed stop behind a timed note
* or train takes that item's time there and the previous stop's time here, so on
* such a day the order stored and the order drawn can differ. The old sort did the
* same.
*/
updateTime(id: string | number, placeTime: unknown, endTime: unknown): AssignmentTimeUpdate {
const sorted = this.dbs.transaction(() => {
const stored = this.dbs.get<{ day_id: number; start: string | null }>(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.id = ?
`, id);
// Falsy times (null, undefined, '') all clear the override — an empty
// string is a clear, not a stored value.
this.dbs.run('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?',
placeTime || null, endTime || null, id);
// Auto-sort: reorder timed assignments chronologically within the day
if (placeTime) {
const assignment = this.dbs.get<{ day_id: number }>('SELECT day_id FROM day_assignments WHERE id = ?', id);
if (assignment) {
// A booked night's hour lives on the booking, not on the stop: nobody types a
// time into a hotel row, they type a check-in. Left out of this, the night
// counted as untimed and stayed wherever it had been dropped, so pinning an
// afternoon stop sorted that one and left the hotel sitting in front of or
// behind it by accident.
const dayAssignments = this.dbs.all<{ id: number; effective_time: string | null }>(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC
`, assignment.day_id);
// Separate timed and untimed, sort timed by time
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter(a => !a.effective_time);
// Interleave: timed in chronological order, untimed keep relative position
const reordered = [...timed, ...untimed];
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
reordered.forEach((a, i) => update.run(i, a.id));
}
}
// Only a start that moved sorts. An end is a label. A start sent again as it
// stood (the place form saving an End, the stay dialog taking one off, an MCP
// call that names only the end) leaves the day the way the traveller left it,
// which can be out of time order on purpose. Compared the way the sort reads
// it, so a visit given the time its place already had moves nothing either. A
// cleared start leaves the day alone too.
if (!placeTime || !stored) return null;
if (sortMinutes(String(placeTime)) === sortMinutes(stored.start)) return null;
return this.sortDayByTime(stored.day_id);
});
return this.getAssignmentWithPlace(Number(id));
return {
assignment: this.getAssignmentWithPlace(Number(id)),
reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null,
vias: sorted?.viasMoved ? { dayId: sorted.dayId, vias: this.listDayVias(sorted.dayId) } : null,
};
}
/**
* Puts one day in time order. Writes nothing when it already is, which is the usual
* case: most starts are typed in the order the day is planned.
*/
private sortDayByTime(dayId: number): { dayId: number; orderedIds: number[]; viasMoved: boolean } | null {
// A booked night's hour lives on the booking, not on the stop: nobody types a
// time into a hotel row, they type a check-in. Left out of this, the night
// counted as untimed and stayed wherever it had been dropped, so pinning an
// afternoon stop sorted that one and left the hotel sitting in front of or
// behind it by accident.
const rows = this.dbs.all<DayStopRow>(`
SELECT da.id, da.order_index, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) as located
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`, dayId);
const sorted = chronoOrder(rows, row => sortMinutes(row.effective_time));
if (sorted.every((row, i) => row === rows[i])) return null;
// Numbered from 0, the way a drag stores a day (`reorderAssignments`). The order
// goes out as a list of ids and every client numbers it by position, so keys kept
// with their gaps would put the day notes and bookings that sort between stops in
// one place for the writer, who reads the day back, and in another for everyone
// else. Only a stop whose key changes is written.
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
sorted.forEach((row, i) => {
if (row.order_index !== i) update.run(i, row.id);
});
return { dayId, orderedIds: sorted.map(row => row.id), viasMoved: this.reanchorVias(dayId, rows, sorted) };
}
/**
* Keeps every drawn road behind the stop it was drawn after, the rule the planner
* applies when stops are dragged (`reanchorByStopOrder`). A via is pinned to a
* POSITION among the day's located stops, so a sort that moves a stop would
* otherwise hand the vias behind it to other legs. Inside the sort's transaction:
* an order without its vias is a road the traveller never drew.
*
* No sequence renumbering, unlike RoadtripService.reanchor: a reorder maps each leg
* onto a different one, so two legs' vias never end up on the same leg.
*/
private reanchorVias(dayId: number, before: DayStopRow[], after: DayStopRow[]): boolean {
const located = (rows: DayStopRow[]) => rows.filter(row => row.located).map(row => row.id);
const previousIds = located(before);
const nextIds = located(after);
// Only stops without coordinates moved. The router never sees those, so every leg
// is still the one it was.
if (previousIds.every((stopId, i) => stopId === nextIds[i])) return false;
// A via behind the day's last stop bends the drive into the next day, on a trip
// with connected days or a night drive (the planner's `anchorFor` files it there).
// `reanchorByStopOrder` gives a last stop no leg and deletes what follows it, which
// is right for a stop the sort made last and wrong for one that was last already.
const lastAt = previousIds.length - 1;
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
.filter(via => via.after_order_index !== seam);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
}
for (const via of plan.vias) {
this.dbs.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
}
return !isEmptyReanchoring(plan);
}
/**
* The day's vias in the shape the road trip routes broadcast them. RoadtripService
* has this query too, but its module imports this one, so it cannot be injected here.
*/
private listDayVias(dayId: number): RoadtripVia[] {
return this.dbs.all<RoadtripVia>(
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
FROM roadtrip_vias
WHERE day_id = ?
ORDER BY after_order_index, sequence, id`,
dayId,
);
}
setEndDay(id: string | number, endDay: boolean) {
@@ -107,6 +107,11 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
'DiscoveryController.protectedResource',
'DiscoveryController.wellKnownFallback',
'DiscoveryController.wellKnownRoot',
// A document provider cannot hold a TREK session. The per-binding token in
// the path is the credential, a shared secret is checked on top where the
// provider can send one, and the handler's only effect is to schedule a sync
// run. It never reads the request body as data.
'DocSyncWebhookController.nudge',
'FeaturesController.features',
// The container/uptime probe.
'FeaturesController.health',
@@ -0,0 +1,504 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DocumentProviderRegistry } from './document-provider.registry';
import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider';
import { docFailed } from './document-provider';
import { decryptSecrets, encryptSecrets, maskSecrets, mergeSecrets } from './doc-sync-secrets';
/**
* Connections and trip bindings: everything a human configures, as opposed to
* what the reconciler does with it.
*
* The connection carries a `trip_id`, and that is the one place this design
* leaves the pattern every other integration in the repo follows. Immich,
* Synology Photos, AirTrail and Dawarich all store credentials per user, and
* `trip_album_links` carries a `user_id` so that photos are shared only through
* an explicit flag. None of that can deliver "everyone on the trip sees the
* same documents": visibility would depend on whose credentials fetched a file,
* and a member without their own Paperless account would see nothing.
*
* So the trip admin binds the trip once, the server talks to the provider under
* that single identity, and TREK's own membership decides who sees what. The
* provider never learns that TREK has members. `owner_user_id` stays explicit
* beside `trip_id` precisely so that the credential holder is nameable: when
* they leave the trip the binding goes to `orphaned` rather than quietly
* carrying on with an ex-member's token.
*/
export interface ConnectionRow {
id: number;
trip_id: number;
provider_id: string;
owner_user_id: number;
base_url: string;
secrets: string | null;
settings: string;
allow_insecure_tls: number;
capabilities: string | null;
last_probe_at: string | null;
last_probe_state: string;
last_probe_error: string | null;
created_at: string;
}
export interface LinkRow {
id: number;
trip_id: number;
connection_id: number;
provider_id: string;
remote_scope_key: string;
remote_root_id: string | null;
remote_root_path: string | null;
remote_label: string;
direction: string;
delete_policy: string;
conflict_policy: string;
sync_enabled: number;
webhook_token: string | null;
webhook_secret: string | null;
webhook_subscription_id: string | null;
remote_cursor: string | null;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
failure_count: number;
next_attempt_at: string | null;
}
interface ProviderFieldRow {
field_key: string;
/** i18n key suffix, never display text: the client resolves `docsync.<label>`. */
label: string;
input_type: string;
placeholder: string | null;
hint: string | null;
secret: number;
required: number;
sort_order: number;
}
@Injectable()
export class DocSyncConfigService {
constructor(
private readonly db: DatabaseService,
private readonly registry: DocumentProviderRegistry,
) {}
// ── Provider metadata ──────────────────────────────────────────────────────
/** Only providers the instance admin switched on may be configured. */
enabledProviderIds(): string[] {
return this.db.connection
.prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all()
.map((r) => (r as { id: string }).id);
}
providerFields(providerId: string): ProviderFieldRow[] {
// Every column the form needs, not just the three the secret bookkeeping
// uses: the client renders the field from this row, and a missing `label`
// turns into a literal "docsync.undefined" on screen.
return this.db.connection
.prepare(
`SELECT field_key, label, input_type, placeholder, hint, secret, required, sort_order
FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order`,
)
.all(providerId) as ProviderFieldRow[];
}
private secretKeys(providerId: string): string[] {
return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key);
}
/**
* The display name whatever the enabled flag says. A binding outlives the
* admin switching its provider off, and the client only gets names for the
* providers that are still on, so without this it would show the raw id.
*/
private providerName(providerId: string): string {
const row = this.db.connection
.prepare('SELECT name FROM document_providers WHERE id = ?')
.get(providerId) as { name: string } | undefined;
return row?.name ?? providerId;
}
// ── Connections ────────────────────────────────────────────────────────────
getConnection(id: number): ConnectionRow | undefined {
return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined;
}
listConnections(tripId: number): ConnectionRow[] {
return this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id')
.all(tripId) as ConnectionRow[];
}
/**
* Turn a stored row into what an adapter needs. Secrets are decrypted here
* and nowhere else, so there is one place to audit and one place that could
* leak them into a log. The ref can write back what the adapter earns, which
* is what makes it a saved connection's ref; see `saveEarnedSecret`.
*/
toRef(row: ConnectionRow): DocumentConnectionRef {
let settings: Record<string, string> = {};
try {
const parsed: unknown = JSON.parse(row.settings || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') settings[k] = v;
}
}
} catch {
settings = {};
}
return {
connectionId: row.id,
createdAt: row.created_at,
ownerId: row.owner_user_id,
baseUrl: row.base_url,
secrets: decryptSecrets(row.secrets),
settings,
allowInsecureTls: row.allow_insecure_tls === 1,
saveSecret: (key, value) => this.saveEarnedSecret(row.id, key, value),
};
}
/**
* Keep a secret the provider earned itself rather than one typed into the
* form, or drop it with null. DSM's device token is the one there is: without
* it, a restart would leave every two-factor NAS waiting for its owner to
* type a fresh code.
*
* It lives in the same encrypted blob as the form's secrets, under a key no
* form field has, and that is what keeps it out of every response
* (`maskSecrets` reports form fields only) and out of the form's reach
* (`mergeSecrets` writes form fields only).
*
* The blob is read back inside the transaction rather than taken from the
* caller's ref: a sync run holds its ref for minutes, and writing that
* snapshot back would undo a password the owner changed in the meantime.
*/
saveEarnedSecret(connectionId: number, key: string, value: string | null): void {
this.db.transaction(() => {
const row = this.getConnection(connectionId);
if (!row) return;
const secrets = decryptSecrets(row.secrets);
if (value === null) delete secrets[key];
else secrets[key] = value;
this.db.connection
.prepare('UPDATE document_connections SET secrets = ? WHERE id = ?')
.run(encryptSecrets(secrets), connectionId);
});
}
/** What a client may see: no secret values, only whether each one is set. */
publicConnection(row: ConnectionRow): Record<string, unknown> {
const ref = this.toRef(row);
return {
id: row.id,
tripId: row.trip_id,
providerId: row.provider_id,
ownerUserId: row.owner_user_id,
baseUrl: row.base_url,
settings: ref.settings,
secrets: maskSecrets(ref.secrets, this.secretKeys(row.provider_id)),
allowInsecureTls: row.allow_insecure_tls === 1,
capabilities: row.capabilities ? safeParse(row.capabilities) : null,
lastProbeAt: row.last_probe_at,
lastProbeState: row.last_probe_state,
lastProbeError: row.last_probe_error,
};
}
/**
* Validate a base URL before it is stored.
*
* A private address is stored WITH a warning rather than refused, because
* self-hosting is the normal case here: a Paperless on 192.168.x is the
* point of the feature, not an attack. That is the same call airtrail.service
* and dawarich.service already made. What is refused is an address that the
* SSRF guard rejects for a reason other than being private.
*/
async validateBaseUrl(raw: string): Promise<DocResult<{ url: string; isPrivate: boolean }>> {
const trimmed = raw.trim().replace(/\/+$/, '');
if (!trimmed) return { success: false, error: { code: 'unreachable', detail: 'empty url' } };
const verdict = await checkSsrf(trimmed);
if (!verdict.allowed && !verdict.isPrivate) {
return { success: false, error: { code: 'ssrf_blocked', detail: verdict.error } };
}
return { success: true, data: { url: trimmed, isPrivate: verdict.isPrivate } };
}
/**
* Create or update the trip's connection for one provider.
*
* Secrets that arrive blank or masked keep their stored value, so a client
* that renders the form from a GET never has to hold the real credential.
* A secret the provider earned itself is kept too, whatever the form sends.
* DSM's device token is stored with the address and account it was issued
* for, so an edit that points the connection somewhere else leaves it
* unusable, and the adapter drops it on its next call.
*/
async upsertConnection(
tripId: number,
userId: number,
input: DocsyncConnectionInput,
): Promise<DocResult<ConnectionRow>> {
const urlCheck = await this.validateBaseUrl(input.baseUrl);
if (docFailed(urlCheck)) return { success: false, error: urlCheck.error };
const fields = this.providerFields(input.providerId);
const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key);
const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key);
const existing = this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?')
.get(tripId, input.providerId) as ConnectionRow | undefined;
const storedSecrets = existing ? decryptSecrets(existing.secrets) : {};
const nextSecrets = mergeSecrets(storedSecrets, input.credentials, secretKeys);
const settings: Record<string, string> = {};
for (const key of plainKeys) {
if (key === 'allow_insecure_tls') continue;
const submitted = input.credentials[key];
if (submitted !== undefined) settings[key] = submitted;
else if (existing) {
const prev = this.toRef(existing).settings[key];
if (prev !== undefined) settings[key] = prev;
}
}
for (const f of fields) {
if (f.required !== 1) continue;
const present = f.secret === 1 ? !!nextSecrets[f.field_key] : !!settings[f.field_key] || f.field_key === 'base_url';
if (!present) {
return { success: false, error: { code: 'unauthorized', detail: `missing required field ${f.field_key}` } };
}
}
const encrypted = encryptSecrets(nextSecrets);
const settingsJson = JSON.stringify(settings);
const insecure = input.allowInsecureTls ? 1 : 0;
const row = this.db.transaction(() => {
if (existing) {
this.db.connection
.prepare(
`UPDATE document_connections
SET base_url = ?, secrets = ?, settings = ?, allow_insecure_tls = ?,
owner_user_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(urlCheck.data.url, encrypted, settingsJson, insecure, userId, existing.id);
return this.getConnection(existing.id) as ConnectionRow;
}
const info = this.db.connection
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings, allow_insecure_tls)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure);
return this.getConnection(Number(info.lastInsertRowid)) as ConnectionRow;
});
return { success: true, data: row };
}
recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): void {
this.db.connection
.prepare(
`UPDATE document_connections
SET last_probe_at = CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?,
capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(state, error, capabilities ? JSON.stringify(capabilities) : null, connectionId);
}
/**
* Deleting a connection leaves every document in place. It unbinds, nothing
* more: the same promise Dawarich's disconnect makes, and the only version
* of this action that is safe to offer without a confirmation dialog.
*/
deleteConnection(id: number): void {
this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
listLinks(tripId: number): LinkRow[] {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id')
.all(tripId) as LinkRow[];
}
getLink(id: number): LinkRow | undefined {
return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined;
}
getLinkByToken(token: string): LinkRow | undefined {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?')
.get(token) as LinkRow | undefined;
}
toScopeRef(link: LinkRow): DocumentScopeRef {
return {
linkId: link.id,
tripId: link.trip_id,
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
cursor: link.remote_cursor,
};
}
/**
* The anchor fields (`remoteRootId`, `remoteRootPath`, `remoteLabel`) are
* what the picker was handed by `listScopes` or `createScope` a moment
* earlier, sent back by the client. Taking them on trust is fine: only the
* trip owner or an instance admin gets here, and an anchor typed in by hand
* still goes through the connection's own credential, so it cannot open
* anything that credential could not open anyway.
*/
createLink(tripId: number, userId: number, input: DocsyncLinkInput): DocResult<LinkRow> {
const conn = this.getConnection(input.connectionId);
if (!conn || conn.trip_id !== tripId) {
return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } };
}
const existing = this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?')
.get(tripId, input.connectionId, input.scopeKey) as LinkRow | undefined;
if (existing) return { success: true, data: existing };
// A per-link token, so revoking one binding cannot be used to poke another,
// and so a leaked webhook URL identifies exactly one trip.
const token = crypto.randomBytes(24).toString('base64url');
const secret = crypto.randomBytes(24).toString('base64url');
const info = this.db.connection
.prepare(
`INSERT INTO trip_document_links
(trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path,
remote_label, direction, delete_policy, conflict_policy, sync_enabled,
webhook_token, webhook_secret, created_by, next_attempt_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
)
.run(
tripId,
input.connectionId,
conn.provider_id,
input.scopeKey,
input.remoteRootId ?? null,
input.remoteRootPath ?? null,
input.remoteLabel ?? '',
input.direction,
input.deletePolicy,
input.conflictPolicy,
input.syncEnabled ? 1 : 0,
token,
String(encryptSecrets({ webhook: secret })),
userId,
);
return { success: true, data: this.getLink(Number(info.lastInsertRowid)) as LinkRow };
}
updateLink(id: number, patch: Partial<DocsyncLinkInput>): LinkRow | undefined {
const sets: string[] = [];
const values: unknown[] = [];
if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); }
if (patch.deletePolicy !== undefined) { sets.push('delete_policy = ?'); values.push(patch.deletePolicy); }
if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); }
if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); }
if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); }
if (sets.length === 0) return this.getLink(id);
sets.push('updated_at = CURRENT_TIMESTAMP');
this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
return this.getLink(id);
}
/**
* Unbinding keeps both copies and drops the pairing rows. It never deletes a
* document on either side. A user who wants that does it deliberately, in
* the system that holds the file.
*/
deleteLink(id: number): void {
this.db.transaction(() => {
this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id);
this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id);
});
}
webhookSecret(link: LinkRow): string {
return decryptSecrets(link.webhook_secret).webhook ?? '';
}
/** The masked view a client gets, with the webhook URL it may need to paste. */
publicLink(link: LinkRow, webhookBaseUrl: string | null): Record<string, unknown> {
return {
id: link.id,
tripId: link.trip_id,
connectionId: link.connection_id,
providerId: link.provider_id,
providerName: this.providerName(link.provider_id),
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
remoteLabel: link.remote_label,
direction: link.direction,
deletePolicy: link.delete_policy,
conflictPolicy: link.conflict_policy,
syncEnabled: link.sync_enabled === 1,
lastSyncAt: link.last_sync_at,
lastSyncState: link.last_sync_state,
lastSyncError: link.last_sync_error,
failureCount: link.failure_count,
// Shown so a user can paste it into a provider that will not let TREK
// subscribe on its own (Papra, and Nextcloud without admin rights).
webhookUrl: webhookBaseUrl && link.webhook_token
? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}`
: null,
webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null,
};
}
/**
* Bindings whose credential owner is no longer on the trip.
*
* Checked on every run rather than hooked to member removal, because a
* membership can also disappear through a trip transfer or a direct DB edit,
* and a binding that keeps using an ex-member's token is the kind of thing
* nobody notices until it is a complaint.
*/
markOrphanedLinks(): number {
const info = this.db.connection
.prepare(
`UPDATE trip_document_links
SET last_sync_state = 'orphaned', sync_enabled = 0, updated_at = CURRENT_TIMESTAMP
WHERE sync_enabled = 1
AND last_sync_state != 'orphaned'
AND connection_id IN (
SELECT c.id FROM document_connections c
WHERE c.owner_user_id NOT IN (
SELECT tm.user_id FROM trip_members tm WHERE tm.trip_id = c.trip_id
UNION SELECT t.user_id FROM trips t WHERE t.id = c.trip_id
)
)`,
)
.run();
return info.changes;
}
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
@@ -0,0 +1,83 @@
import { DOCSYNC_SECRET_MASK } from '@trek/shared';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
* Credential handling for document connections.
*
* All secret fields of a connection live in ONE encrypted JSON column rather
* than a column per provider. A sixth provider then needs no migration, and the
* key rotation in scripts/migrate-encryption.ts stays a single line instead of
* a field list somebody will extend and forget. A forgotten column does not
* survive a rotation, and the failure only shows up months later as a
* connection that cannot authenticate any more.
*/
export type SecretMap = Record<string, string>;
/** Encrypt the whole secret map for storage. Empty maps store as NULL. */
export function encryptSecrets(secrets: SecretMap): string | null {
const entries = Object.entries(secrets).filter(([, v]) => typeof v === 'string' && v.length > 0);
if (entries.length === 0) return null;
return String(maybe_encrypt_api_key(JSON.stringify(Object.fromEntries(entries))));
}
/**
* Decrypt for use. A value that cannot be decrypted returns an empty map rather
* than throwing: the caller then reports `unauthorized` and the user re-enters
* the credential, which is the recoverable outcome. That is the same choice
* synology.service.ts makes for a stale SID: a decryption failure after an
* ENCRYPTION_KEY rotation must not turn into a boot-time crash.
*/
export function decryptSecrets(stored: string | null | undefined): SecretMap {
if (!stored) return {};
const plain = decrypt_api_key(stored);
if (typeof plain !== 'string' || plain.length === 0) return {};
try {
const parsed: unknown = JSON.parse(plain);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out: SecretMap = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
return {};
}
}
/**
* Merge submitted form values over the stored ones.
*
* A secret field that arrives blank, absent, or as the mask keeps its stored
* value. That rule is what lets the client render a connection form without
* ever holding the secret it was given back, the same contract the photo
* provider form and Dawarich already use, so the generic component needs no
* special case here.
*
* A stored key that is no form field is a secret the provider earned itself
* (DSM's device token). It is carried over untouched and cannot be set from
* here: the form never saw it, so an edit can neither wipe it nor plant one.
*/
export function mergeSecrets(stored: SecretMap, submitted: Record<string, string>, secretKeys: readonly string[]): SecretMap {
const next: SecretMap = { ...stored };
for (const key of secretKeys) {
const value = submitted[key];
if (value === undefined) continue;
if (value === '' || value === DOCSYNC_SECRET_MASK) continue;
next[key] = value;
}
return next;
}
/**
* What a client is allowed to see: whether a secret is set, never its value.
* Form fields only, so a secret the provider earned itself is not even
* reported as present.
*/
export function maskSecrets(secrets: SecretMap, secretKeys: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const key of secretKeys) {
if (secrets[key]) out[key] = DOCSYNC_SECRET_MASK;
}
return out;
}
@@ -0,0 +1,155 @@
import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
import { DatabaseService } from '../database/database.service';
import { SETTING_SYNC_ENABLED, WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
/**
* `/api/docsync/webhook/:token`: the one endpoint a provider calls.
*
* Deliberately the thinnest thing in the domain. It answers 200 and schedules a
* run; it never reads the body as truth. Every provider here has a different
* payload, none of them signs it in a way all five share, Paperless gives its
* webhook five seconds before it retries, and Nextcloud's payload carries a
* path that is different for every member of a share. Treating any of that as
* data would mean trusting an unauthenticated stranger's description of what
* changed. So the webhook means exactly one thing: look now.
*
* `@Public` because a provider cannot hold a TREK session. The token in the URL
* is the authentication, one per binding, so a leaked URL can only ever nudge
* the one trip it belongs to, and nudging is all it can do. Where the provider
* supports it, a shared secret is checked as well.
*/
@Controller('api/docsync/webhook')
export class DocSyncWebhookController implements OnModuleDestroy {
/**
* One pending run per binding, so a burst folds into a single pass.
*
* Providers fire per document: dropping twenty files into a watched Nextcloud
* folder is twenty calls within a second or two. Each one used to start its
* own run, which the service's in-flight guard then answered with `busy`,
* so nineteen changes were announced and thrown away, and the one run that
* did start had begun before most of them landed. Collecting them for a beat
* and then running once is both less work and more correct.
*/
private readonly pending = new Map<number, ReturnType<typeof setTimeout>>();
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly db: DatabaseService,
) {}
/**
* The same switches the scheduler obeys: the Documents addon, the binding's
* provider, and the instance-wide kill switch.
*
* Without them "switch document sync off" meant "stop the poll", while every
* provider holding a webhook kept driving full runs: an admin turning the
* addon off would have watched it carry on. Checked when the timer fires as
* well as on arrival, so a switch thrown during the debounce window still
* takes effect.
*/
private syncIsOn(link: LinkRow): boolean {
if (this.sync.isSwitchedOff(link)) return false;
const killSwitch = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
)?.value;
// Unrecognised values mean ON: the setting is absent by default, and only an
// explicit 'false' stops the sync. Same rule as the job.
return killSwitch !== 'false';
}
onModuleDestroy(): void {
for (const timer of this.pending.values()) clearTimeout(timer);
this.pending.clear();
}
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
if (!this.syncIsOn(link)) return { received: true };
const secret = this.config.webhookSecret(link);
if (secret && !this.secretMatches(req, secret)) return { received: true };
// Fire and forget. Paperless allows five seconds before it counts the call
// as failed and retries, and a sync run takes longer than that whenever
// there is anything to do, so the answer goes out now and the run happens
// after the debounce window, by which time the rest of the burst has
// arrived and been folded into this same timer.
this.schedule(link.id, () => this.config.getLink(link.id));
return { received: true };
}
/**
* Start one run per binding per window, trailing rather than leading.
*
* Trailing on purpose: the first call of a burst is the least informed one,
* because the provider is usually still writing the rest. The link is looked
* up again when the timer fires, so a binding switched off or deleted in the
* meantime does not get one last run out of a stale row.
*/
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>, isRetry = false): void {
if (this.pending.has(linkId)) return;
const timer = setTimeout(() => {
this.pending.delete(linkId);
const fresh = reload();
if (!fresh || fresh.sync_enabled !== 1) return;
if (!this.syncIsOn(fresh)) return;
void this.sync.syncLink(fresh).then((res) => {
// A run that was already in flight answers `busy`, and the changes this
// nudge was about may have landed after that run read the folder. Ask
// again once rather than waiting out a whole poll interval: once, and
// only for `busy`, so this cannot become a loop.
if (res?.state === 'busy' && !isRetry) this.schedule(linkId, reload, true);
});
}, WEBHOOK_NUDGE_DEBOUNCE_SECONDS * 1000);
// A pending nudge must not hold the process open at shutdown.
if (typeof timer.unref === 'function') timer.unref();
this.pending.set(linkId, timer);
}
/**
* Accept either a plain shared-secret header (Paperless workflows, Nextcloud
* `authMethod: header`) or Papra's standard-webhooks HMAC. Compared in
* constant time, and a mismatch is silently ignored rather than reported, so
* the endpoint tells a prober nothing either way.
*/
private secretMatches(req: Request, secret: string): boolean {
const header = req.get('x-trek-docsync-secret');
if (header && timingSafeEqualStr(header, secret)) return true;
const sig = req.get('webhook-signature');
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
if (sig && id && ts) {
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
const body = raw ? raw.toString('utf8') : JSON.stringify(req.body ?? {});
const expected = crypto
.createHmac('sha256', Buffer.from(secret))
.update(`${id}.${ts}.${body}`)
.digest('base64');
for (const part of sig.split(' ')) {
const value = part.startsWith('v1,') ? part.slice(3) : part;
if (timingSafeEqualStr(value, expected)) return true;
}
}
return false;
}
}
function timingSafeEqualStr(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
@@ -0,0 +1,94 @@
/**
* Tuning constants for the document sync core.
*
* Kept out of the service so the pure planner can be unit-tested against them
* without a container, and so a reviewer can see every limit in one place
* instead of hunting for magic numbers.
*/
/** Per-request ceiling for provider calls that return metadata. */
export const PROVIDER_TIMEOUT_MS = 15_000;
/** Transfers get longer, because a 40 MB PDF over a home upstream is not fast. */
export const PROVIDER_TRANSFER_TIMEOUT_MS = 120_000;
/** Metadata responses are JSON; nothing legitimate here is megabytes. */
export const PROVIDER_JSON_MAX_BYTES = 8 * 1024 * 1024;
/**
* Page cap per listing run, mirroring the reason dawarich.client.ts states: a
* provider that keeps answering `next` must not hold a worker forever. Hitting
* it sets `truncated`, which the core surfaces rather than silently treating a
* partial listing as the whole truth.
*/
export const PROVIDER_MAX_PAGES = 40;
/**
* The share of a link's known documents that may disappear in one run before
* the run refuses to act on any of it.
*
* An unmounted Synology share, a revoked Nextcloud share and an expired
* Paperless token all produce the same thing: a listing far shorter than the
* last one. Without this guard the reconciler would read that as a mass
* deletion. Below the threshold, vanished documents are recorded as
* `remote_missing` and wait for a human either way. This only decides whether
* the run trusts the listing enough to record anything at all.
*/
export const MASS_DELETE_RATIO = 0.4;
/** Below this count the ratio is meaningless, so the guard only counts. */
export const MASS_DELETE_MIN_ITEMS = 5;
/**
* Backoff for a failing link, in seconds, indexed by consecutive failures.
* Flat 30 minutes after the sixth, because a provider that has been down for
* three hours is a configuration problem, not a blip, and hammering it earns a
* DSM auto-block.
*/
export const LINK_BACKOFF_SECONDS = [60, 300, 900, 1800, 3600, 7200] as const;
/** Per-document retry curve, shorter: usually a transient upload failure. */
export const ITEM_BACKOFF_SECONDS = [30, 120, 600, 3600] as const;
/** After this many attempts a document stops retrying and waits for a human. */
export const ITEM_MAX_ATTEMPTS = 6;
/**
* Consecutive link failures after which the circuit opens and the job stops
* scheduling it at all until someone presses "sync now".
*/
export const LINK_CIRCUIT_OPEN_AFTER = 10;
/** Default poll interval. Clamped, overridable per instance in app_settings. */
export const DEFAULT_POLL_INTERVAL_SECONDS = 300;
export const MIN_POLL_INTERVAL_SECONDS = 60;
export const MAX_POLL_INTERVAL_SECONDS = 3600;
/**
* How long a webhook may accelerate a link before the poll takes over again.
* A webhook only ever means "look now". This keeps a link whose subscription
* silently died from going stale forever.
*/
export const WEBHOOK_NUDGE_DEBOUNCE_SECONDS = 5;
/** Documents pulled or pushed per run, so one huge trip cannot starve others. */
export const MAX_TRANSFERS_PER_RUN = 25;
/**
* What a manual run answers for a binding an admin has switched off, through
* its provider or through the Documents addon.
*
* Not one of the shared DocsyncErrorCodes: those say what went wrong between
* TREK and a provider, and here nothing has. It is never written to the binding
* either, which is what lets the binding resume as it was left.
*/
export const PROVIDER_DISABLED = 'provider_disabled';
/** app_settings keys, read per tick so an admin toggle needs no restart. */
export const SETTING_POLL_INTERVAL = 'docsync_poll_interval_seconds';
export const SETTING_SYNC_ENABLED = 'docsync_sync_enabled';
// There is deliberately no size key here: a document that arrives through a
// binding is bound by the same ceiling as one somebody uploads (MAX_FILE_SIZE in
// files.constants), checked against the announced size and again while the
// bytes stream. A second, sync-only limit would be a number nobody maintains
// and a way for the two paths to disagree about what fits.
@@ -0,0 +1,439 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { docFailed } from './document-provider';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import { DocumentProviderRegistry } from './document-provider.registry';
import { PROVIDER_DISABLED } from './doc-sync.constants';
import {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
DocsyncLinkDto,
DocsyncLinkUpdateDto,
DocsyncResolveConflictDto,
DocsyncScopeCreateDto,
DocsyncSyncNowDto,
} from './doc-sync.dto';
/**
* `/api/trips/:tripId/docsync`: a trip's document provider binding.
*
* Trip-scoped rather than user-scoped, and that is the whole point of the
* feature: one connection per trip, which every member reads through. The
* routes therefore sit under the trip, not under `/api/integrations`.
*
* Two shapes of answer, the split Dawarich already established: connection
* routes (`test`, `status`) answer 200 even when the provider is unreachable,
* with the reason in the body, because a form showing someone their own typo
* needs a field to render rather than an exception. Everything else answers the
* status the failure deserves.
*
* Only the trip owner may change a binding (plus an instance admin, who
* overrides trip permissions everywhere else too). It hands TREK a credential
* that usually reaches the owner's entire document archive, so widening this to
* every member would let any member point the trip at a folder the owner never
* meant to share. Reading the status is open to all members: they need to know
* where their documents are going.
*/
@Controller('api/trips/:tripId/docsync')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.DOCUMENTS, 'Documents')
export class DocSyncController {
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly registry: DocumentProviderRegistry,
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
) {}
/**
* Tell the trip that a binding came or went.
*
* Whether a trip is bound decides whether its members see the sync button
* at all, and a run only pings when it moved a document. Binding two empty
* sides, or unbinding, moves nothing, so a member sitting on the Files tab
* kept the old answer until they left it. The whole room, the caller
* included: it is a ping to re-read, not a change to apply, and the owner's
* own button depends on it too once every provider is switched off.
*/
private announceBinding(link: Pick<LinkRow, 'id' | 'trip_id'>): void {
this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled: 0, pushed: 0 });
}
/**
* The trip owner or an instance admin, the same rule as `canManageDocSync`
* on the client. Checked here rather than through @RequirePermission: the
* permission table has no "manage integrations" action and inventing one
* would grant it to every trip_member by default, which is the opposite of
* what a credential this broad needs.
*
* The 403 still says "owner": an admin never sees it, and to everybody who
* does, the owner is the person to ask.
*/
private assertCanManage(tripId: string, user: User): void {
if (user.role === 'admin') return;
const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) throw new HttpException('Trip not found', 404);
if (Number(trip.user_id) !== Number(user.id)) {
throw new HttpException('Only the trip owner can change document sync', 403);
}
}
// ── Providers and status ───────────────────────────────────────────────────
/** Which providers this instance has switched on, with their form fields. */
@Get('providers')
providers() {
const rows = this.db.connection
.prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
return rows.map((p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
}));
}
@Get('status')
status(@Param('tripId') tripId: string) {
return this.sync.status(Number(tripId));
}
// ── Connections ────────────────────────────────────────────────────────────
@Get('connections')
listConnections(@Param('tripId') tripId: string) {
return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c));
}
@Put('connections')
@HttpCode(200)
async upsertConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionDto,
) {
this.assertCanManage(tripId, user);
if (!this.config.enabledProviderIds().includes(body.providerId)) {
throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400);
}
const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
return this.config.publicConnection(res.data);
}
/**
* Probe form values. Always 200: the result is in the body, because this is
* a form field, not a failure of the request. The photo providers pin the
* same contract with a test their e2e suite marks CRITICAL.
*/
@Post('connections/test')
@HttpCode(200)
async testConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionTestDto,
) {
this.assertCanManage(tripId, user);
const provider = this.registry.get(body.providerId);
if (!provider) return { connected: false, error: 'unknown_provider' };
const urlCheck = await this.config.validateBaseUrl(body.baseUrl);
if (docFailed(urlCheck)) return { connected: false, error: urlCheck.error.code };
// Probing with the values on screen, merged over whatever is stored, so a
// user testing an unchanged connection does not have to retype the secret.
const existing = this.config
.listConnections(Number(tripId))
.find((c) => c.provider_id === body.providerId);
// Only for the address the credential was stored against. Merging it into a
// probe of an arbitrary baseUrl turns this route into a way to have TREK
// post a stored API token at a server of the caller's choosing, which is
// exactly what somebody who inherited a trip but not its credentials would
// reach for. Same host, same scheme, same port, or the caller types it in.
const sameTarget = !!existing && sameOrigin(existing.base_url, urlCheck.data.url);
const stored = sameTarget && existing ? this.config.toRef(existing) : null;
const fields = this.config.providerFields(body.providerId);
const secrets: Record<string, string> = { ...(stored?.secrets ?? {}) };
const settings: Record<string, string> = { ...(stored?.settings ?? {}) };
for (const f of fields) {
const submitted = body.credentials[f.field_key];
if (submitted === undefined || submitted === '') continue;
if (f.secret === 1) secrets[f.field_key] = submitted;
else settings[f.field_key] = submitted;
}
const res = await provider.probe({
connectionId: existing?.id ?? 0,
createdAt: existing?.created_at ?? '',
ownerId: Number(user.id),
baseUrl: urlCheck.data.url,
secrets,
settings,
allowInsecureTls: body.allowInsecureTls,
});
if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail };
if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
return { connected: true, account: res.data.account, capabilities: res.data.capabilities };
}
@Delete('connections/:connectionId')
@HttpCode(200)
deleteConnection(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
// Its bindings go with it (ON DELETE CASCADE), so read them first.
const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id);
this.config.deleteConnection(conn.id);
for (const link of unbound) this.announceBinding(link);
return { success: true };
}
// ── Scopes ─────────────────────────────────────────────────────────────────
/** Folders, tags or spaces the trip could be bound to. */
@Get('connections/:connectionId/scopes')
async listScopes(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Query('q') q?: string,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.listScopes(this.config.toRef(conn), q);
if (docFailed(res)) return { scopes: [], error: res.error.code };
return { scopes: res.data };
}
@Post('connections/:connectionId/scopes')
async createScope(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Body() body: DocsyncScopeCreateDto,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.createScope(this.config.toRef(conn), body.name);
if (docFailed(res)) throw new HttpException(res.error.code, 400);
return res.data;
}
// ── Bindings ───────────────────────────────────────────────────────────────
@Get('links')
listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
const base = publicOrigin(req);
return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base));
}
@Post('links')
@HttpCode(200)
async createLink(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const res = this.config.createLink(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
this.announceBinding(res.data);
// Subscribe where the provider lets TREK do it itself. A failure here is
// not a failure of the binding (polling still carries it), so it is logged
// into the link state rather than thrown at the user.
const conn = this.config.getConnection(res.data.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.registerWebhook) {
const base = publicOrigin(req);
if (base) {
const hook = await provider.registerWebhook(
this.config.toRef(conn),
this.config.toScopeRef(res.data),
`${base}/api/docsync/webhook/${res.data.webhook_token}`,
this.config.webhookSecret(res.data),
);
if (hook.success) {
this.db.connection
.prepare('UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?')
.run(hook.data.subscriptionId, res.data.id);
}
}
}
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req));
}
@Patch('links/:linkId')
@HttpCode(200)
updateLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkUpdateDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const updated = this.config.updateLink(link.id, body);
return this.config.publicLink(updated ?? link, publicOrigin(req));
}
@Delete('links/:linkId')
@HttpCode(200)
async deleteLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const conn = this.config.getConnection(link.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) {
await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id);
}
this.config.deleteLink(link.id);
this.announceBinding(link);
// Unbinding keeps both copies. Nothing is deleted anywhere.
return { success: true, documentsKept: true };
}
@Post('links/:linkId/sync')
@HttpCode(200)
async syncNow(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Body() body: DocsyncSyncNowDto,
) {
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// An orphaned binding stays orphaned: its credential belongs to somebody who
// is no longer on this trip, and a manual run would use it anyway. The
// scheduler already refuses it (sync_enabled is 0), so without this the
// button was a way around that.
if (link.last_sync_state === 'orphaned') {
throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409);
}
// Refused before the shelved rows are touched, so a binding an admin
// switched off stays exactly as it was and resumes where it stopped. A code
// rather than a sentence: the client says it in the reader's language.
if (this.sync.isSwitchedOff(link)) {
throw new HttpException({ error: PROVIDER_DISABLED }, 409);
}
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
// ── Documents and conflicts ────────────────────────────────────────────────
@Get('items')
items(@Param('tripId') tripId: string, @Query('state') state?: string) {
const rows = state
? this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId), state)
: this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId));
return rows;
}
@Post('items/:itemId/resolve')
@HttpCode(200)
async resolve(
@Param('tripId') tripId: string,
@Param('itemId') itemId: string,
@CurrentUser() user: User,
@Body() body: DocsyncResolveConflictDto,
) {
this.assertCanManage(tripId, user);
const ok = await this.sync.resolveConflict(Number(itemId), body.keep, Number(tripId));
if (!ok) throw new HttpException('Item is not in conflict', 400);
return { success: true };
}
}
/** Whether two URLs address the same server, for deciding if a stored secret may be reused. */
function sameOrigin(a: string | null | undefined, b: string): boolean {
if (!a) return false;
try {
const x = new URL(a);
const y = new URL(b);
return x.protocol === y.protocol && x.host === y.host;
} catch {
return false;
}
}
/**
* The origin a provider has to call back on.
*
* Derived from the request rather than from configuration because a self-hosted
* TREK is reached under whatever name its operator chose, and asking them to
* maintain a second copy of it would get it wrong. Returns null behind a proxy
* that strips the host, in which case the webhook is simply not offered and
* polling carries the binding.
*/
function publicOrigin(req: Request): string | null {
const host = req.get('x-forwarded-host') || req.get('host');
if (!host) return null;
const proto = (req.get('x-forwarded-proto') || req.protocol || 'http').split(',')[0].trim();
return `${proto}://${host}`;
}
+24
View File
@@ -0,0 +1,24 @@
import { createZodDto } from 'nestjs-zod';
import {
docsyncConnectionInputSchema,
docsyncConnectionTestSchema,
docsyncLinkInputSchema,
docsyncLinkUpdateSchema,
docsyncResolveConflictSchema,
docsyncScopeCreateSchema,
docsyncSyncNowSchema,
} from '@trek/shared';
/**
* Zod DTOs for every mutating route in this domain.
*
* The boot-time ratchet in common/validate-body-contracts.ts refuses to start
* if a mutation takes an unwrapped body, so these are not optional decoration.
*/
export class DocsyncConnectionDto extends createZodDto(docsyncConnectionInputSchema) {}
export class DocsyncConnectionTestDto extends createZodDto(docsyncConnectionTestSchema) {}
export class DocsyncLinkDto extends createZodDto(docsyncLinkInputSchema) {}
export class DocsyncLinkUpdateDto extends createZodDto(docsyncLinkUpdateSchema) {}
export class DocsyncScopeCreateDto extends createZodDto(docsyncScopeCreateSchema) {}
export class DocsyncResolveConflictDto extends createZodDto(docsyncResolveConflictSchema) {}
export class DocsyncSyncNowDto extends createZodDto(docsyncSyncNowSchema) {}

Some files were not shown because too many files have changed in this diff Show More