fix(docsync): offer the sync button only where something is behind it

The Documents addon put a "Document sync" button on every trip's Files
tab, and the seed ships every provider switched off, so after the
upgrade each of those buttons led into "No document providers are
available". The button now shows on a trip that is already bound, to
every member, and otherwise only to somebody who may bind the trip on
an instance that offers a provider. Without it the Files toolbar is
the one from before the feature, trash flush right, on both shells.

/api/addons carries no document providers for a normal user, so the
answer comes from the docsync routes through a new online-only
docSyncRepo and is kept per session in docSyncOfferStore. A revisit
renders the last answer at once and checks it again; offline nothing
is asked and the last answer stands. Creating a binding, removing one
and removing a connection now ping the trip with docsync:changed and
zero counts, so a member already on the Files tab sees the button come
and go without leaving it.

Both shells keep listing a binding whose provider the admin has since
switched off instead of claiming there are no providers, and name its
store through one storeName() helper. A load that answers 403 or 404
clears the bindings as well, so they do not linger once the Documents
addon is gone.

The phone admin screen gets the Documents shelf the desktop one has:
the document providers as sub rows under the addon, toggled through the
same admin route and re-read after Documents itself is toggled, because
the server switches the providers off with it.

isTripOwner and the isOwner prop become canManageSync and canManage,
which is what canManageDocSync answers. Both folder pickers take
loadScopes out of the sync object before their effect, so
exhaustive-deps is satisfied without the effect running on every
render.
This commit is contained in:
Maurice
2026-09-19 13:36:33 +02:00
parent 25d4e34959
commit 843582863c
24 changed files with 938 additions and 203 deletions
@@ -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();
});
});
});
+1 -1
View File
@@ -33,7 +33,7 @@ export default function FileManager(props: FileManagerProps) {
<DocSyncPanel
tripId={props.tripId}
tripTitle={S.trip?.title}
isOwner={S.isTripOwner}
canManage={S.canManageSync}
onClose={() => S.setShowDocSync(false)}
/>
)}
@@ -2,7 +2,7 @@ import { FolderSync, Star, Trash2 } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
export function FileManagerToolbar(S: FileManagerState) {
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync } = 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={{
@@ -65,22 +65,27 @@ 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. */}
<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>
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,
flexShrink: 0, marginLeft: docSyncOffered ? undefined : 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync'
import type { DocSyncLink, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
@@ -12,15 +12,6 @@ const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
const provider: DocSyncProvider = {
id: 'paperless',
name: 'Paperless',
description: null,
icon: 'paperless',
available: true,
fields: [],
}
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
@@ -71,17 +62,16 @@ function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
isOwner?: boolean
/** null means "the provider list has not arrived yet". */
provider?: DocSyncProvider | null
canManage?: boolean
providerName?: string
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
provider={opts.provider === null ? undefined : (opts.provider ?? provider)}
providerName={opts.providerName ?? 'Paperless'}
sync={makeSync(opts.sync)}
isOwner={opts.isOwner ?? true}
canManage={opts.canManage ?? true}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
@@ -96,7 +86,7 @@ beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding — running a sync', () => {
describe('DocSyncBinding: running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
@@ -131,7 +121,7 @@ describe('DocSyncBinding — running a sync', () => {
})
})
describe('DocSyncBinding — what it says about itself', () => {
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()
@@ -193,11 +183,12 @@ describe('DocSyncBinding — what it says about itself', () => {
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: without a provider record the card falls back to the provider id', () => {
const { header } = renderCard({ provider: null })
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')).toBeInTheDocument()
expect(within(header).queryByText('Paperless')).not.toBeInTheDocument()
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', () => {
@@ -211,7 +202,7 @@ describe('DocSyncBinding — what it says about itself', () => {
})
})
describe('DocSyncBinding — the settings section', () => {
describe('DocSyncBinding: the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
@@ -231,7 +222,7 @@ describe('DocSyncBinding — the settings section', () => {
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ isOwner: false })
renderCard({ canManage: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
@@ -275,7 +266,7 @@ describe('DocSyncBinding — the settings section', () => {
})
})
describe('DocSyncBinding — the webhook URL', () => {
describe('DocSyncBinding: the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
@@ -328,7 +319,7 @@ describe('DocSyncBinding — the webhook URL', () => {
})
})
describe('DocSyncBinding — the direction lanes', () => {
describe('DocSyncBinding: the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
@@ -338,7 +329,7 @@ describe('DocSyncBinding — the direction lanes', () => {
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ isOwner: false })
renderCard({ canManage: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
@@ -9,27 +9,27 @@ 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 type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync'
import type { DocSyncLink, 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
* is about. Everything a person sets once and forgets (the delete rule, the
* webhook URL) is folded away behind "Settings" so the card stays a status
* card rather than a form.
*/
export default function DocSyncBinding({
link,
provider,
providerName,
sync,
isOwner,
canManage,
}: {
link: DocSyncLink
provider?: DocSyncProvider
providerName: string
sync: ReturnType<typeof useDocSync>
isOwner: boolean
canManage: boolean
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -59,15 +59,15 @@ export default function DocSyncBinding({
<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">
{provider?.name || link.providerId}
{providerName}
</span>
{/* A healthy binding needs no word for it — the dot is the whole
{/* 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
{/* 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}>
@@ -82,7 +82,7 @@ export default function DocSyncBinding({
</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. */}
other: the two together read as one segmented control. */}
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
@@ -97,7 +97,7 @@ export default function DocSyncBinding({
text still changes to "Syncing". */}
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
</button>
{isOwner && (
{canManage && (
<Tooltip label={t('docsync.unlink')}>
<button
type="button"
@@ -126,15 +126,15 @@ export default function DocSyncBinding({
<DocSyncFlow
direction={link.direction}
providerId={link.providerId}
providerName={provider?.name || link.providerId}
providerName={providerName}
running={busy}
holdings={link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }}
disabled={!isOwner}
disabled={!canManage}
onChange={(next: SyncDirection) => void sync.updateLink(link.id, { direction: next })}
/>
</div>
{isOwner && (
{canManage && (
<>
<button
type="button"
@@ -183,7 +183,7 @@ export default function DocSyncBinding({
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
{/* 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>
@@ -1,12 +1,12 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-016
// 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
* 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.
@@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { act, render, screen, within, fireEvent, waitFor } from '../../../../tests/helpers/render'
/** What the dialog actually renders for a key — the same fallback chain `t()` uses. */
/** 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()
@@ -118,12 +118,12 @@ beforeEach(() => {
syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
})
describe('DocSyncPanel — first load', () => {
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} isOwner onClose={onClose} />)
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()
@@ -136,7 +136,7 @@ describe('DocSyncPanel — first load', () => {
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} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByRole('navigation')).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
@@ -145,24 +145,58 @@ describe('DocSyncPanel — first load', () => {
it('FE-DOCSYNC-PANEL-003: says there is nothing to configure when the instance offers no provider', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
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} isOwner onClose={onClose} />)
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', () => {
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: [
@@ -173,7 +207,7 @@ describe('DocSyncPanel — the store list', () => {
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
const connected = group(t('docsync.sidebar.connected'))
@@ -189,7 +223,7 @@ describe('DocSyncPanel — the store list', () => {
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} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
expect(screen.getByRole('heading', { name: t('docsync.addProvider') })).toBeInTheDocument()
@@ -203,7 +237,7 @@ describe('DocSyncPanel — the store list', () => {
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
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
@@ -221,7 +255,7 @@ describe('DocSyncPanel — the store list', () => {
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const second = within(sidebar()).getByRole('button', { name: /Nextcloud/ })
@@ -236,14 +270,14 @@ describe('DocSyncPanel — the store list', () => {
})
})
describe('DocSyncPanel — a member rather than the owner', () => {
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} isOwner={false} onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
await screen.findByRole('navigation')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
@@ -254,7 +288,7 @@ describe('DocSyncPanel — a member rather than the owner', () => {
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} isOwner={false} onClose={onClose} />)
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()
@@ -262,20 +296,20 @@ describe('DocSyncPanel — a member rather than the owner', () => {
})
})
describe('DocSyncPanel — removing a binding', () => {
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.
// reacts to, so that is where the row disappears.
deleteLink.mockImplementation(async () => {
serverHas({ providers: both, links: [paperless] })
return {}
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
@@ -296,7 +330,7 @@ describe('DocSyncPanel — removing a binding', () => {
return {}
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
@@ -307,11 +341,11 @@ describe('DocSyncPanel — removing a binding', () => {
})
})
describe('DocSyncPanel — what needs a look', () => {
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} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
@@ -324,7 +358,7 @@ describe('DocSyncPanel — what needs a look', () => {
items: { pending: 9, synced: 41, local_deleted: 2 },
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
@@ -337,7 +371,7 @@ describe('DocSyncPanel — what needs a look', () => {
items: { conflict: 2, too_large: 1, pending: 9, synced: 41 },
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const panel = within(
@@ -356,11 +390,11 @@ describe('DocSyncPanel — what needs a look', () => {
})
})
describe('DocSyncPanel — the dialog itself', () => {
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" isOwner onClose={onClose} />)
render(<DocSyncPanel tripId={7} tripTitle="Iceland 2026" canManage onClose={onClose} />)
await screen.findByText(t('docsync.noProviders'))
expect(screen.getByText(t('docsync.title'))).toBeInTheDocument()
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { StateBadge } from './DocSyncBits'
import { useConflicts } from './useConflicts'
import { useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync'
import { storeName, useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync'
import DocSyncBinding from './DocSyncBinding'
import DocSyncConnectModal from './DocSyncConnectModal'
import DocSyncScopeModal from './DocSyncScopeModal'
@@ -21,19 +21,19 @@ import DocSyncScopeModal from './DocSyncScopeModal'
*
* 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
* 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,
isOwner,
canManage,
onClose,
}: {
tripId: number | string
tripTitle?: string
isOwner: boolean
canManage: boolean
onClose: () => void
}) {
const { t } = useTranslation()
@@ -74,7 +74,9 @@ export default function DocSyncPanel({
<div className="grid place-items-center py-20">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
) : sync.providers.length === 0 ? (
) : 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">
@@ -83,7 +85,7 @@ export default function DocSyncPanel({
providers={sync.providers}
available={available}
activeId={active?.id ?? null}
isOwner={isOwner}
canManage={canManage}
onSelect={setSelected}
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
@@ -93,14 +95,14 @@ export default function DocSyncPanel({
<div className="space-y-5">
<DocSyncBinding
link={active}
provider={sync.providers.find(p => p.id === active.providerId)}
providerName={storeName(active, sync.providers)}
sync={sync}
isOwner={isOwner}
canManage={canManage}
/>
{attention > 0 && <Attention counts={sync.itemCounts} tripId={tripId} sync={sync} />}
</div>
) : (
<NothingBound isOwner={isOwner} />
<NothingBound canManage={canManage} />
)}
</div>
</div>
@@ -138,7 +140,7 @@ function Sidebar({
providers,
available,
activeId,
isOwner,
canManage,
onSelect,
onAdd,
}: {
@@ -146,7 +148,7 @@ function Sidebar({
providers: DocSyncProvider[]
available: DocSyncProvider[]
activeId: number | null
isOwner: boolean
canManage: boolean
onSelect: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
@@ -163,7 +165,7 @@ function Sidebar({
<li key={link.id}>
<StoreButton
providerId={link.providerId}
title={providers.find(p => p.id === link.providerId)?.name || link.providerId}
title={storeName(link, providers)}
subtitle={link.remoteLabel || link.remoteRootPath || link.scopeKey}
state={link.lastSyncState}
active={link.id === activeId}
@@ -175,7 +177,7 @@ function Sidebar({
</div>
)}
{isOwner && available.length > 0 && (
{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')}
@@ -340,7 +342,7 @@ function Attention({
}
/** The detail column before anything is bound: an invitation, not a blank. */
function NothingBound({ isOwner }: { isOwner: boolean }) {
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">
@@ -350,7 +352,7 @@ function NothingBound({ isOwner }: { isOwner: boolean }) {
</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">
{isOwner ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
{canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
</p>
</div>
</div>
@@ -12,7 +12,7 @@ import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync'
* 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.
* because for a new trip it is the common case: there is no folder yet.
*/
export default function DocSyncScopeModal({
connection,
@@ -37,20 +37,22 @@ export default function DocSyncScopeModal({
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(() => {
// `sync.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.
let cancelled = false
void (async () => {
const res = await sync.loadScopes(connection.id)
const res = await loadScopes(connection.id)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connection.id, sync.loadScopes])
}, [connection.id, loadScopes])
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
@@ -1,4 +1,4 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-022
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-024
/**
* The document-sync hook.
@@ -40,7 +40,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
}
})
import { useDocSync, canManageDocSync } from './useDocSync'
import { useDocSync, canManageDocSync, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
const TRIP = 3
@@ -179,6 +179,21 @@ describe('useDocSync initial load', () => {
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)
@@ -374,6 +389,18 @@ describe('useDocSync run and save feedback', () => {
})
})
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)
@@ -64,10 +64,28 @@ export interface DocSyncLink {
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 }
}
/**
* 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
}
/**
* Whether this person may change a sync binding.
*
@@ -76,7 +94,7 @@ export interface DocSyncLink {
* 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
* 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(
@@ -100,7 +118,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
* 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
* 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)
@@ -108,7 +126,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
/**
* Which load is the current one.
*
* Four requests go out per load and several loads overlap — every mutation
* 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.
@@ -144,15 +162,20 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
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
// 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))
@@ -191,7 +214,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object — the route
* 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.
*/
@@ -216,7 +239,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
/**
* Make a container at the provider.
*
* Reports failure the same way every other write here does — a rejected
* 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
@@ -254,7 +277,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
* 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
* 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.
*/
@@ -266,8 +289,8 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
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.
// 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
@@ -299,8 +322,8 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
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
// 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()
@@ -334,7 +357,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
* 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
* 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`.
@@ -342,7 +365,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
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
// 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
@@ -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
}
@@ -8,6 +8,7 @@ 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'
@@ -42,7 +43,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const currentUser = useAuthStore((s) => s.user)
const isTripOwner = canManageDocSync(currentUser, trip)
const canManageSync = canManageDocSync(currentUser, trip)
const docSyncOffered = useDocSyncOffered(tripId, canManageSync)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -215,7 +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, isTripOwner,
showDocSync, setShowDocSync, canManageSync, docSyncOffered,
}
}
@@ -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 }>
+22 -4
View File
@@ -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
@@ -675,9 +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)} />
<MIconBtn ariaLabel={t('docsync.title')} onClick={() => setOpenDocSyncSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<FolderSync size={15} strokeWidth={2} />
</MIconBtn>
<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>
@@ -775,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,4 +1,4 @@
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-016
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-017
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
@@ -18,7 +18,7 @@ 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.
* (`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
@@ -97,7 +97,7 @@ const onClose = vi.fn()
function renderSheet(over: Partial<ComponentProps<typeof MDocSyncSheet>> = {}) {
return render(
<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open onClose={onClose} {...over} />,
<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" canManage open onClose={onClose} {...over} />,
)
}
@@ -141,7 +141,7 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('MDocSyncSheet — opening and the store list', () => {
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 })
@@ -180,14 +180,34 @@ describe('MDocSyncSheet — opening and the store list', () => {
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', () => {
describe('MDocSyncSheet: moving between the views', () => {
it('FE-DOCSYNC-MOBILE-005: tapping a bound store opens its detail view', async () => {
renderSheet()
@@ -219,8 +239,8 @@ describe('MDocSyncSheet — moving between the views', () => {
const { rerender } = renderSheet()
await openDetail()
rerender(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open={false} onClose={onClose} />)
rerender(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open onClose={onClose} />)
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()
@@ -255,7 +275,7 @@ describe('MDocSyncSheet — moving between the views', () => {
})
})
describe('MDocSyncSheet — the two lanes', () => {
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()
@@ -291,7 +311,7 @@ describe('MDocSyncSheet — the two lanes', () => {
})
})
describe('MDocSyncSheet — what the owner may do', () => {
describe('MDocSyncSheet: what the owner may do', () => {
it('FE-DOCSYNC-MOBILE-012: the auto-sync switch patches the binding', async () => {
renderSheet()
await openDetail()
@@ -330,9 +350,9 @@ describe('MDocSyncSheet — what the owner may do', () => {
})
})
describe('MDocSyncSheet — what a member may do', () => {
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({ isOwner: false })
renderSheet({ canManage: false })
await openDetail()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
@@ -350,7 +370,7 @@ describe('MDocSyncSheet — what a member may do', () => {
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({ isOwner: false })
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()
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
import TrekIcon from '../../../../components/shared/TrekIcon'
import {
useDocSync, type DocSyncLink, type DocSyncProvider,
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'
@@ -23,7 +23,7 @@ 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
* 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.
*
@@ -36,13 +36,13 @@ type View = 'list' | 'detail' | 'connect' | 'scope'
export default function MDocSyncSheet({
tripId,
tripTitle,
isOwner,
canManage,
open,
onClose,
}: {
tripId: number | string
tripTitle?: string
isOwner: boolean
canManage: boolean
open: boolean
onClose: () => void
}) {
@@ -77,8 +77,8 @@ export default function MDocSyncSheet({
const title =
view === 'connect' || view === 'scope'
? pending?.name ?? t('docsync.title')
: view === 'detail'
? sync.providers.find(p => p.id === link?.providerId)?.name ?? t('docsync.title')
: view === 'detail' && link
? storeName(link, sync.providers)
: t('docsync.title')
return (
@@ -108,7 +108,8 @@ export default function MDocSyncSheet({
<div className="flex justify-center py-12">
<Loader2 size={20} className="animate-spin text-m-faint" />
</div>
) : sync.providers.length === 0 ? (
) : 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
@@ -127,9 +128,9 @@ export default function MDocSyncSheet({
<DetailView
tripId={tripId}
link={link}
providerName={sync.providers.find(p => p.id === link.providerId)?.name ?? link.providerId}
providerName={storeName(link, sync.providers)}
sync={sync}
isOwner={isOwner}
canManage={canManage}
onUnlink={() => setConfirmUnlink(true)}
/>
) : (
@@ -137,7 +138,7 @@ export default function MDocSyncSheet({
links={sync.links}
providers={sync.providers}
available={available}
isOwner={isOwner}
canManage={canManage}
onOpen={id => { setSelected(id); setView('detail') }}
onAdd={openStore}
/>
@@ -167,14 +168,14 @@ function ListView({
links,
providers,
available,
isOwner,
canManage,
onOpen,
onAdd,
}: {
links: DocSyncLink[]
providers: DocSyncProvider[]
available: DocSyncProvider[]
isOwner: boolean
canManage: boolean
onOpen: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
@@ -189,7 +190,7 @@ function ListView({
<StoreRow
key={l.id}
providerId={l.providerId}
title={providers.find(p => p.id === l.providerId)?.name || l.providerId}
title={storeName(l, providers)}
sub={l.remoteLabel || l.remoteRootPath || l.scopeKey}
state={l.lastSyncState}
onClick={() => onOpen(l.id)}
@@ -199,7 +200,7 @@ function ListView({
</section>
)}
{isOwner && available.length > 0 && (
{canManage && available.length > 0 && (
<section>
<SectionLabel>{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}</SectionLabel>
<div className="flex flex-col gap-2">
@@ -217,7 +218,7 @@ function ListView({
</section>
)}
{links.length === 0 && !isOwner && (
{links.length === 0 && !canManage && (
<Empty text={t('docsync.empty.title')} hint={t('docsync.empty.hintMember')} />
)}
</div>
@@ -230,14 +231,14 @@ function DetailView({
tripId,
providerName,
sync,
isOwner,
canManage,
onUnlink,
}: {
link: DocSyncLink
tripId: number | string
providerName: string
sync: ReturnType<typeof useDocSync>
isOwner: boolean
canManage: boolean
onUnlink: () => void
}) {
const { t, language } = useTranslation()
@@ -249,7 +250,7 @@ function DetailView({
/** The last remaining direction cannot be switched off. */
const toggle = (lane: 'push' | 'pull') => {
if (!isOwner) return
if (!canManage) return
const nextPush = lane === 'push' ? !pushOn : pushOn
const nextPull = lane === 'pull' ? !pullOn : pullOn
if (!nextPush && !nextPull) return
@@ -278,14 +279,14 @@ function DetailView({
active={pushOn}
label={t('docsync.flow.toProvider')}
icon={<ArrowRight size={13} strokeWidth={2.6} />}
disabled={!isOwner}
disabled={!canManage}
onClick={() => toggle('push')}
/>
<LaneRow
active={pullOn}
label={t('docsync.flow.toTrek')}
icon={<ArrowLeft size={13} strokeWidth={2.6} />}
disabled={!isOwner}
disabled={!canManage}
onClick={() => toggle('pull')}
/>
</div>
@@ -316,7 +317,7 @@ function DetailView({
{busy ? t('docsync.syncing') : t('docsync.syncNow')}
</button>
{isOwner && (
{canManage && (
<>
<SettingRow label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<MToggle
@@ -367,8 +368,8 @@ function DetailView({
/**
* 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
* 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.
*/
@@ -485,20 +486,22 @@ function ScopeView({
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(() => {
// `sync.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.
let cancelled = false
void (async () => {
const res = await sync.loadScopes(connectionId)
const res = await loadScopes(connectionId)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connectionId, sync.loadScopes])
}, [connectionId, loadScopes])
const bind = async (scope: { scopeKey: string; label: string; remoteRootId: string | null; remoteRootPath: string | null }) => {
setWorking(scope.scopeKey)
@@ -757,7 +760,7 @@ function slugFor(title: string | undefined, tripId: number | string): string {
/**
* The documents that changed in both places, with the way out.
*
* The same three answers the panel offers, through the same hook — only the
* The same three answers the panel offers, through the same hook. Only the
* markup is a phone's.
*/
function MConflicts({
@@ -228,7 +228,7 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
<MDocSyncSheet
tripId={planner.tripId}
tripTitle={planner.trip?.title}
isOwner={canManageDocSync(currentUser, planner.trip)}
canManage={canManageDocSync(currentUser, planner.trip)}
open={docSyncOpen}
onClose={() => setDocSyncOpen(false)}
/>
+44
View File
@@ -0,0 +1,44 @@
import { docsyncApi } from '../api/client'
import { isEffectivelyOffline } from '../sync/networkMode'
/**
* The two reads behind the Files screen's "Document sync" button.
*
* Online-only with no Dexie table, for the reason `dawarichRepo` gives: both
* answers are the server's (which providers the admin switched on, whether the
* owner bound this trip), and the dialog the button opens cannot load offline
* either. The store in front of this keeps the last answer for the session.
*/
export class DocSyncOfflineError extends Error {
constructor() {
super('Document sync needs a connection')
this.name = 'DocSyncOfflineError'
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new DocSyncOfflineError()
}
export const docSyncRepo = {
/**
* Whether the instance offers a provider somebody could connect.
*
* The list is instance-wide; the route sits under a trip only because every
* document-sync route does. `available` is the same filter the dialog
* applies, so a provider row without an adapter does not bring up a button
* into an empty list.
*/
async offersProvider(tripId: number | string): Promise<boolean> {
requireOnline()
const providers = (await docsyncApi.providers(tripId)) as Array<{ available?: boolean }>
return providers.some(p => p.available)
},
/** Whether the trip is bound to at least one store. */
async isBound(tripId: number | string): Promise<boolean> {
requireOnline()
const links = (await docsyncApi.listLinks(tripId)) as unknown[]
return links.length > 0
},
}
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand'
import { docSyncRepo } from '../repo/docSyncRepo'
/**
* Whether a trip's Files screen offers document sync, remembered for the session.
*
* The seed ships every document provider switched off, so a button on every
* trip led, straight after the upgrade, into "No document providers are
* available". It shows where there is something behind it: a trip that is
* already bound (every member needs to see where their documents go), or
* somebody who may bind it on an instance that offers a provider.
*
* Remembered so that coming back to the tab renders the last answer at once,
* instead of the button popping in after a request every time.
*/
interface DocSyncOfferState {
/** Per trip id, whether it has a binding. Missing until asked. */
bound: Record<string, boolean>
/** Whether the instance offers any provider. Null until asked. */
providers: boolean | null
refresh: (tripId: number | string, canManage: boolean) => Promise<void>
}
export const useDocSyncOfferStore = create<DocSyncOfferState>((set) => ({
bound: {},
providers: null,
refresh: async (tripId, canManage) => {
const key = String(tripId)
try {
// Somebody who may bind the trip gets the button as soon as a provider
// is on, bound or not, so the second request is for everybody else.
if (canManage) {
const providers = await docSyncRepo.offersProvider(tripId)
set({ providers })
if (providers) return
}
const bound = await docSyncRepo.isBound(tripId)
set(s => ({ bound: { ...s.bound, [key]: bound } }))
} catch (err) {
// 403 is the Documents addon switched off and 404 a trip this person no
// longer reaches: both a real "no". Anything else, offline included, is
// no answer at all, and the last one stands.
const status = (err as { response?: { status?: number } })?.response?.status
if (status === 403 || status === 404) set(s => ({ bound: { ...s.bound, [key]: false } }))
}
},
}))
@@ -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 />);
@@ -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 },
@@ -22,8 +22,9 @@ 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 } from './doc-sync-config.service';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import { DocumentProviderRegistry } from './document-provider.registry';
import {
@@ -37,7 +38,7 @@ import {
} from './doc-sync.dto';
/**
* `/api/trips/:tripId/docsync` — a trip's document provider binding.
* `/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
@@ -53,7 +54,7 @@ import {
* 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
* 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')
@@ -65,8 +66,23 @@ export class DocSyncController {
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
@@ -132,7 +148,7 @@ export class DocSyncController {
}
/**
* Probe form values. Always 200 — the result is in the body, because this is
* 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.
*/
@@ -157,7 +173,7 @@ export class DocSyncController {
.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
// 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);
@@ -174,6 +190,7 @@ export class DocSyncController {
const res = await provider.probe({
connectionId: existing?.id ?? 0,
createdAt: existing?.created_at ?? '',
ownerId: Number(user.id),
baseUrl: urlCheck.data.url,
secrets,
@@ -190,13 +207,15 @@ export class DocSyncController {
deleteConnection(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
createdAt: existing?.created_at ?? '',
@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 };
}
@@ -256,9 +275,10 @@ export class DocSyncController {
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
// 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;
@@ -318,6 +338,7 @@ export class DocSyncController {
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 };
}
@@ -12,7 +12,7 @@ import type { Request } from 'express';
* form field that must not lose a secret it never saw, and a webhook whose
* callback URL is derived from the request rather than from configuration.
*
* The config service and the provider registry are the real ones — the merge
* The config service and the provider registry are the real ones: the merge
* this file pins runs through actual encryption and actual provider-field rows,
* and a stub of either would only be a restatement of the controller. The
* reconciler is a spy: a run is a side effect here, not the subject.
@@ -71,6 +71,7 @@ import type {
} from '../../../../src/nest/doc-sync/document-provider';
import type { LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
import type {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
@@ -123,7 +124,8 @@ const sync = {
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
const dbs = new DatabaseService(testDb);
const config = new DocSyncConfigService(dbs, registry);
const controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs);
const realtime = { broadcast: vi.fn() };
const controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService);
/** Only what publicOrigin reads: header lookup plus the connection's own scheme. */
function makeReq(headers: Record<string, string> = {}, protocol = 'http'): Request {
@@ -368,8 +370,6 @@ describe('probing form values', () => {
});
});
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', () => {
describe('a secret the adapter earned itself', () => {
const EARNED = 'a1b2c3:DEVICE-SECRET';
@@ -416,6 +416,8 @@ describe('a secret the adapter earned itself', () => {
});
});
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', () => {
controller.status(String(tripId));
expect(sync.status).toHaveBeenCalledWith(tripId);
});
@@ -439,6 +441,32 @@ describe('removing a connection', () => {
const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getConnection(conn.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
it('pings the trip once for every binding that went with it, and for no other', async () => {
const conn = await storedPaperless();
const cloud = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
}));
if ('error' in cloud) throw new Error(`fixture failed: ${cloud.error.code}`);
const bind = (connectionId: number, scopeKey: string) => {
const res = config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey }));
if ('error' in res) throw new Error(`fixture failed: ${res.error.code}`);
return res.data.id;
};
const first = bind(conn.id, 'tag:1');
const second = bind(conn.id, 'tag:2');
bind(cloud.data.id, 'folder:1');
controller.deleteConnection(String(tripId), String(conn.id), owner);
expect(realtime.broadcast.mock.calls).toEqual([
[tripId, 'docsync:changed', { linkId: first, pulled: 0, pushed: 0 }],
[tripId, 'docsync:changed', { linkId: second, pulled: 0, pushed: 0 }],
]);
});
});
@@ -556,6 +584,14 @@ describe('creating a binding', () => {
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
});
it('pings the whole trip, so a member already on the Files tab gets the sync button', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
// Three arguments: no socket is skipped, the owner's own button reads it too.
expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'docsync:changed', { linkId: link.id, pulled: 0, pushed: 0 });
});
it('refuses a binding for a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() =>
@@ -563,6 +599,7 @@ describe('creating a binding', () => {
);
expect(err.getStatus()).toBe(400);
expect(sync.syncLink).not.toHaveBeenCalled();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
});
@@ -682,12 +719,26 @@ describe('removing a binding', () => {
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
it('pings the trip that the binding is gone', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
realtime.broadcast.mockClear();
await controller.deleteLink(String(tripId), String(created.id), owner);
expect(realtime.broadcast.mock.calls).toEqual([
[tripId, 'docsync:changed', { linkId: created.id, pulled: 0, pushed: 0 }],
]);
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
realtime.broadcast.mockClear();
const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getLink(created.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
});