mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })]));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }]))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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 } }))
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 })] } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user