diff --git a/client/src/components/Files/docsync/DocSyncBinding.test.tsx b/client/src/components/Files/docsync/DocSyncBinding.test.tsx index 01e96d039..985093b29 100644 --- a/client/src/components/Files/docsync/DocSyncBinding.test.tsx +++ b/client/src/components/Files/docsync/DocSyncBinding.test.tsx @@ -1,4 +1,4 @@ -// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030 +// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import en from '@trek/shared/i18n/en' import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render' @@ -66,6 +66,7 @@ function renderCard( canManage?: boolean providerName?: string onReconnect?: () => void + onEditConnection?: () => void } = {}, ) { const utils = render( @@ -75,6 +76,7 @@ function renderCard( sync={makeSync(opts.sync)} canManage={opts.canManage ?? true} onReconnect={opts.onReconnect} + onEditConnection={opts.onEditConnection} />, ) const header = utils.container.querySelector('header') as HTMLElement @@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => { expect(updateLink).not.toHaveBeenCalled() }) }) + +describe('DocSyncBinding: the connection behind it', () => { + const S = en as unknown as Record + const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] }) + + it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => { + const onEditConnection = vi.fn() + renderCard({ link: { lastSyncState: 'ok' }, onEditConnection }) + + expect(editButton()).not.toBeInTheDocument() + openSettings() + expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument() + fireEvent.click(editButton() as HTMLElement) + + expect(onEditConnection).toHaveBeenCalledTimes(1) + }) + + it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => { + const member = renderCard({ canManage: false, onEditConnection: vi.fn() }) + expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument() + expect(editButton()).not.toBeInTheDocument() + member.unmount() + + renderCard() + openSettings() + expect(editButton()).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncBinding.tsx b/client/src/components/Files/docsync/DocSyncBinding.tsx index 8c5042a13..0d8699d34 100644 --- a/client/src/components/Files/docsync/DocSyncBinding.tsx +++ b/client/src/components/Files/docsync/DocSyncBinding.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { - AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw, + AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw, } from 'lucide-react' import ConfirmDialog from '../../shared/ConfirmDialog' import CustomSelect from '../../shared/CustomSelect' @@ -27,6 +27,7 @@ export default function DocSyncBinding({ sync, canManage, onReconnect, + onEditConnection, }: { link: DocSyncLink providerName: string @@ -38,6 +39,8 @@ export default function DocSyncBinding({ * not carry; left out, a refused credential is reported but not curable. */ onReconnect?: () => void + /** The same form at any time, to change the address or the login, or to remove the connection. */ + onEditConnection?: () => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -237,6 +240,21 @@ export default function DocSyncBinding({ )} + + {onEditConnection && ( + + + + + + )} )} diff --git a/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx b/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx index f2414c048..7e2a835c5 100644 --- a/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx +++ b/client/src/components/Files/docsync/DocSyncConnectModal.test.tsx @@ -1,4 +1,4 @@ -// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027 +// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030 import { describe, it, expect, vi, beforeEach } from 'vitest' import en from '@trek/shared/i18n/en' import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render' @@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise => [] const status = vi.fn(async (_tripId: number | string): Promise => ({ items: {}, links: [] })) const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ id: 1 })) const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ connected: true })) +const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise => ({ success: true })) vi.mock('../../../api/client', async (importOriginal) => { const actual = await importOriginal() @@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => { listLinks: (tripId: number | string) => listLinks(tripId), saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data), testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data), + deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id), }, } }) @@ -81,11 +83,13 @@ function Harness({ provider, onClose, onConnected, + onRemoved, waitForConnection, }: { provider: DocSyncProvider onClose: () => void onConnected: (providerId: string) => void + onRemoved?: () => void /** * Hold the dialog back until the stored connection is in hand, the way the * panel does: it only offers "connect" once it knows what is already there. @@ -94,7 +98,9 @@ function Harness({ }) { const sync = useDocSync(TRIP_ID, true) if (waitForConnection && !sync.connectionFor(provider.id)) return null - return + return ( + + ) } /** Let the hook's initial load land before anything is asserted. */ @@ -492,3 +498,49 @@ describe('DocSyncConnectModal with a connection already stored', () => { expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' })) }) }) + +describe('DocSyncConnectModal removal', () => { + const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') }) + // The confirmation is portalled after the dialog, so its button is the last one of that name. + const confirmRemoval = () => screen.getAllByRole('button', { name: tx('docsync.connection.remove') }).at(-1)! + + async function openWithRemoval(stored: boolean) { + if (stored) listConnections.mockResolvedValue([connection()]) + const onRemoved = vi.fn() + render() + await flush() + return onRemoved + } + + it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => { + const onRemoved = await openWithRemoval(true) + await waitFor(() => expect(removeButton()).toBeInTheDocument()) + + fireEvent.click(removeButton()!) + expect(deleteConnection).not.toHaveBeenCalled() + await screen.findByText(tx('docsync.connection.confirmRemove')) + fireEvent.click(confirmRemoval()) + + await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9)) + await waitFor(() => expect(onRemoved).toHaveBeenCalled()) + }) + + it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => { + deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } }) + const onRemoved = await openWithRemoval(true) + await waitFor(() => expect(removeButton()).toBeInTheDocument()) + + fireEvent.click(removeButton()!) + await screen.findByText(tx('docsync.connection.confirmRemove')) + fireEvent.click(confirmRemoval()) + + await waitFor(() => expect(deleteConnection).toHaveBeenCalled()) + expect(onRemoved).not.toHaveBeenCalled() + expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument() + }) + + it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => { + await openWithRemoval(false) + expect(removeButton()).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncConnectModal.tsx b/client/src/components/Files/docsync/DocSyncConnectModal.tsx index 2ae95ce21..62b9173b8 100644 --- a/client/src/components/Files/docsync/DocSyncConnectModal.tsx +++ b/client/src/components/Files/docsync/DocSyncConnectModal.tsx @@ -1,6 +1,7 @@ import { useId, useState } from 'react' import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react' -import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell' +import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell' +import ConfirmDialog from '../../shared/ConfirmDialog' import { Tooltip } from '../../shared/Tooltip' import ToggleSwitch from '../../Settings/ToggleSwitch' import { useTranslation } from '../../../i18n/TranslationContext' @@ -22,11 +23,14 @@ export default function DocSyncConnectModal({ sync, onClose, onConnected, + onRemoved, }: { provider: DocSyncProvider sync: ReturnType onClose: () => void onConnected: (providerId: string) => void + /** After the stored connection was removed from here; left out, the form offers no removal. */ + onRemoved?: () => void }) { const { t } = useTranslation() const existing = sync.connectionFor(provider.id) @@ -34,6 +38,11 @@ export default function DocSyncConnectModal({ const form = useConnectForm(provider, existing, sync) const [verdict, setVerdict] = useState(null) + const [confirmRemove, setConfirmRemove] = useState(false) + + const remove = async () => { + if (existing && (await sync.removeConnection(existing.id))) onRemoved?.() + } const test = async () => setVerdict(await form.probe()) const labelId = useId() @@ -46,6 +55,7 @@ export default function DocSyncConnectModal({ {Icon ? : }} @@ -57,6 +67,13 @@ export default function DocSyncConnectModal({ )} footer={( + {existing && onRemoved && ( + setConfirmRemove(true)} + disabled={sync.busy === 'connection'} + /> + )} void test()} disabled={!form.complete || sync.busy === 'test'}> @@ -101,6 +118,15 @@ export default function DocSyncConnectModal({ + + setConfirmRemove(false)} + onConfirm={() => void remove()} + title={t('docsync.connection.remove')} + message={t('docsync.connection.confirmRemove')} + confirmLabel={t('docsync.connection.remove')} + /> ) } diff --git a/client/src/components/Files/docsync/DocSyncPanel.tsx b/client/src/components/Files/docsync/DocSyncPanel.tsx index d12c9b5fd..339058abd 100644 --- a/client/src/components/Files/docsync/DocSyncPanel.tsx +++ b/client/src/components/Files/docsync/DocSyncPanel.tsx @@ -62,6 +62,9 @@ export default function DocSyncPanel({ setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null))) }, [sync.links]) + // The connection behind the folder picker, which the picker can send back to. + const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined + const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0) const labelId = useId() @@ -113,6 +116,7 @@ export default function DocSyncPanel({ sync={sync} canManage={canManage} onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined} + onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined} /> {/* Every action in this column reports here: a refused run, a switch that flipped back, a conflict the server would not @@ -141,6 +145,7 @@ export default function DocSyncPanel({ sync={sync} onClose={() => setConnecting(null)} onConnected={id => { setConnecting(null); setScopeFor(id) }} + onRemoved={() => setConnecting(null)} /> )} @@ -149,6 +154,7 @@ export default function DocSyncPanel({ provider={reconnecting} sync={sync} onClose={() => setReconnecting(null)} + onRemoved={() => setReconnecting(null)} onConnected={id => { setReconnecting(null) // Run straight away, as binding does: the card still reports the @@ -168,6 +174,7 @@ export default function DocSyncPanel({ sync={sync} onClose={() => setScopeFor(null)} onBound={() => setScopeFor(null)} + onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined} /> )} diff --git a/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx b/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx index f9c0ebdd7..297a0ef44 100644 --- a/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx +++ b/client/src/components/Files/docsync/DocSyncScopeModal.test.tsx @@ -1,4 +1,4 @@ -// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023 +// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024 /** * The folder picker of the document-sync dialog. @@ -84,9 +84,10 @@ function deferred() { return { promise, resolve, reject } } -function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: { +function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: { suggestedName?: string providerName?: string + onEditConnection?: () => void }) { const sync = useDocSync(TRIP_ID, false) return ( @@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' } sync={sync} onClose={onClose} onBound={onBound} + onEditConnection={onEditConnection} /> ) } @@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => { expect(createButton()).toBeEnabled() }) }) + +describe('DocSyncScopeModal connection', () => { + it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => { + const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] }) + const onEditConnection = vi.fn() + const first = render() + await waitFor(() => expect(listScopes).toHaveBeenCalled()) + + fireEvent.click(editButton() as HTMLElement) + expect(onEditConnection).toHaveBeenCalledTimes(1) + expect(createLink).not.toHaveBeenCalled() + first.unmount() + + render() + expect(editButton()).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Files/docsync/DocSyncScopeModal.tsx b/client/src/components/Files/docsync/DocSyncScopeModal.tsx index 55f1c0df1..41c22f1cf 100644 --- a/client/src/components/Files/docsync/DocSyncScopeModal.tsx +++ b/client/src/components/Files/docsync/DocSyncScopeModal.tsx @@ -1,5 +1,5 @@ import { useId, useMemo, useState } from 'react' -import { FolderPlus, Loader2, Search } from 'lucide-react' +import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react' import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell' import { useTranslation } from '../../../i18n/TranslationContext' import type { DocSyncConnection, useDocSync } from './useDocSync' @@ -22,6 +22,7 @@ export default function DocSyncScopeModal({ sync, onClose, onBound, + onEditConnection, }: { connection: DocSyncConnection providerName: string @@ -30,6 +31,8 @@ export default function DocSyncScopeModal({ sync: ReturnType onClose: () => void onBound: () => void + /** Back to the stored connection, to change the login or remove it before choosing a folder. */ + onEditConnection?: () => void }) { const { t } = useTranslation() const labelId = useId() @@ -63,7 +66,19 @@ export default function DocSyncScopeModal({ )} >
-

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

+
+

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

+ {onEditConnection && ( + + )} +
{t('docsync.scope.createTitle')} diff --git a/client/src/components/Files/docsync/useDocSync.test.ts b/client/src/components/Files/docsync/useDocSync.test.ts index 48fc21a7b..f7758b4e7 100644 --- a/client/src/components/Files/docsync/useDocSync.test.ts +++ b/client/src/components/Files/docsync/useDocSync.test.ts @@ -1,4 +1,4 @@ -// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030 +// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032 /** * The document-sync hook. @@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown) const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise => ({})) const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise => ({})) const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise => ({ scopes: [] })) +const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise => ({ success: true })) const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise => ({ success: true })) vi.mock('../../../api/client', async (importOriginal) => { @@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => { syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full), listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q), resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep), + deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id), }, } }) @@ -126,6 +128,7 @@ beforeEach(() => { syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) listScopesApi.mockResolvedValue({ scopes: [] }) resolveApi.mockResolvedValue({ success: true }) + deleteConnectionApi.mockResolvedValue({ success: true }) }) describe('useDocSync initial load', () => { @@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => { }) }) +describe('useDocSync removeConnection', () => { + it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => { + const { result } = await mountLoaded() + expect(result.current.links).toHaveLength(1) + // The server drops the bindings with the connection (ON DELETE CASCADE). + listConnectionsApi.mockResolvedValue([]) + listLinksApi.mockResolvedValue([]) + + let ok: boolean | undefined + await act(async () => { ok = await result.current.removeConnection(9) }) + + expect(ok).toBe(true) + expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9) + expect(result.current.connectionFor('nextcloud')).toBeNull() + expect(result.current.links).toEqual([]) + expect(result.current.busy).toBeNull() + }) + + it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => { + deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } }) + const { result } = await mountLoaded() + + let ok: boolean | undefined + await act(async () => { ok = await result.current.removeConnection(9) }) + + expect(ok).toBe(false) + expect(result.current.error).toBe('forbidden') + expect(result.current.connectionFor('nextcloud')).not.toBeNull() + expect(result.current.busy).toBeNull() + }) +}) + describe('needsReauth', () => { it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => { expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true) diff --git a/client/src/components/Files/docsync/useDocSync.ts b/client/src/components/Files/docsync/useDocSync.ts index 8f1861451..8145e0321 100644 --- a/client/src/components/Files/docsync/useDocSync.ts +++ b/client/src/components/Files/docsync/useDocSync.ts @@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) { } }, [tripId, load]) + /** + * Forget a stored connection, and with it every binding of this trip that + * runs through it. The documents stay on both sides; what goes is the + * address and the login TREK kept for the store, which is the one thing a + * person changing accounts or folders had no way to take back. + */ + const removeConnection = useCallback(async (connectionId: number) => { + setBusy('connection') + setError(null) + try { + await docsyncApi.deleteConnection(tripId, connectionId) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + /** * Probe without saving. Always resolves to a verdict object: the route * answers 200 even for an unreachable instance, because a form showing a typo @@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) { connections, links, itemCounts, loading, busy, error, connectionFor, load, - saveConnection, testConnection, + saveConnection, testConnection, removeConnection, loadScopes, createScope, createLink, updateLink, removeLink, syncNow, resolveConflict, } diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx index e21a4f56b..c7af2e9e3 100644 --- a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx +++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx @@ -1,4 +1,4 @@ -// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024 +// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027 import type { ComponentProps } from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render' @@ -137,6 +137,12 @@ beforeEach(() => { vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({}) vi.spyOn(docsyncApi, 'items').mockResolvedValue([]) vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true }) + vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => { + // The bindings go with their connection, as ON DELETE CASCADE has it. + connections = connections.filter(c => c.id !== id) + links = links.filter(l => l.connectionId !== id) + return { success: true } + }) }) /** The status route with a number of conflicts on it, holdings as usual. */ @@ -528,3 +534,43 @@ describe('MDocSyncSheet: what a member may do', () => { expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument() }) }) + +describe('MDocSyncSheet: the stored connection', () => { + const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' }) + + it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => { + renderSheet() + await openDetail() + + fireEvent.click(screen.getByRole('button', { name: 'Edit connection' })) + expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example') + + fireEvent.click(removeButtons()[0]) + expect(docsyncApi.deleteConnection).not.toHaveBeenCalled() + expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument() + fireEvent.click(removeButtons().at(-1)!) + + await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5)) + // Back at the list, with Paperless offered again from scratch. + expect(await screen.findByText('Connect a provider')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => { + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ })) + await screen.findByText('Photos') + + fireEvent.click(screen.getByRole('button', { name: 'Edit connection' })) + + expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => { + renderSheet({ canManage: false }) + await openDetail() + + expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument() + }) +}) diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx index 160235640..40cfa891f 100644 --- a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off, - Loader2, Plus, RefreshCw, + Loader2, Plus, RefreshCw, Trash2, } from 'lucide-react' import MSheet from '../../../components/MSheet' import MIconBtn from '../../../components/MIconBtn' @@ -149,6 +149,7 @@ export default function MDocSyncSheet({ provider={pending} sync={sync} onDone={connected} + onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }} /> ) : view === 'scope' && pending && sync.connectionFor(pending.id) ? ( setView('list')} + onEditConnection={() => setView('connect')} /> ) : view === 'detail' && link ? ( + {onReconnect && ( + + + + )} +
+ + {existing && ( + + )} + + setConfirmRemove(false)} + title={t('docsync.connection.remove')} + message={t('docsync.connection.confirmRemove')} + confirmLabel={t('docsync.connection.remove')} + cancelLabel={t('common.cancel')} + danger + onConfirm={() => { + setConfirmRemove(false) + if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() }) + }} + />
) } @@ -544,11 +589,14 @@ function ScopeView({ suggestedName, sync, onBound, + onEditConnection, }: { connectionId: number suggestedName: string sync: ReturnType onBound: () => void + /** Back to the stored connection, to change the login or remove it before choosing a folder. */ + onEditConnection: () => void }) { const { t } = useTranslation() const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({ @@ -562,6 +610,15 @@ function ScopeView({

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

+ +