fix(docsync): let the trip owner edit or remove a stored document-store connection

Once a store was connected for a trip, its address and login could only be
entered again after a sync run had been refused, and the stored connection
could not be removed at all: the server's DELETE route had no way in from the
app. A binding's settings now open the connection at any time, the folder
picker leads back to it before a folder is chosen, and the connection form
removes a stored connection after a confirmation, on the desktop panel and the
phone sheet alike. Removing it drops the trip's bindings through it; documents
stay on both sides.

Closes #2842
This commit is contained in:
Maurice
2026-10-27 14:45:17 +01:00
parent 0f0232491b
commit 9e216f5ae4
38 changed files with 503 additions and 14 deletions
@@ -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<string, string>
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()
})
})
@@ -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({
</div>
</div>
)}
{onEditConnection && (
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<span className="flex justify-end">
<button
type="button"
onClick={onEditConnection}
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
</span>
</Row>
)}
</div>
)}
</>
@@ -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<unknown> => []
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
@@ -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 <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
return (
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
)
}
/** 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(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
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()
})
})
@@ -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<typeof useDocSync>
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<DocSyncVerdict | null>(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({
<DialogShell
onClose={onClose}
labelledBy={labelId}
blocked={confirmRemove}
header={(
<DialogHeader
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
)}
footer={(
<DialogFooter>
{existing && onRemoved && (
<DeleteButton
label={t('docsync.connection.remove')}
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
/>
)}
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<FooterSpacer />
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
</span>
</label>
</div>
<ConfirmDialog
isOpen={confirmRemove}
onClose={() => setConfirmRemove(false)}
onConfirm={() => void remove()}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
/>
</DialogShell>
)
}
@@ -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}
/>
)}
</>
@@ -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<T>() {
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(<Harness onEditConnection={onEditConnection} />)
await waitFor(() => expect(listScopes).toHaveBeenCalled())
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
expect(createLink).not.toHaveBeenCalled()
first.unmount()
render(<Harness />)
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -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<typeof useDocSync>
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({
)}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="flex items-start justify-between gap-3">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
{onEditConnection && (
<button
type="button"
onClick={onEditConnection}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
)}
</div>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
@@ -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<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ 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)
@@ -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,
}
@@ -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()
})
})
@@ -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) ? (
<ScopeView
@@ -156,6 +157,7 @@ export default function MDocSyncSheet({
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onBound={() => setView('list')}
onEditConnection={() => setView('connect')}
/>
) : view === 'detail' && link ? (
<DetailView
@@ -417,6 +419,19 @@ function DetailView({
</button>
</SettingRow>
{onReconnect && (
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<button
type="button"
onClick={onReconnect}
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
</SettingRow>
)}
<button
type="button"
onClick={onUnlink}
@@ -443,14 +458,18 @@ function ConnectView({
provider,
sync,
onDone,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onDone: () => void
onRemoved: () => void
}) {
const { t } = useTranslation()
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
const existing = sync.connectionFor(provider.id)
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
return (
<div className="mt-3 flex flex-col gap-3">
@@ -534,6 +553,32 @@ function ConnectView({
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button>
</div>
{existing && (
<button
type="button"
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
>
<Trash2 size={15} strokeWidth={2.2} />
{t('docsync.connection.remove')}
</button>
)}
<MConfirmSheet
open={confirmRemove}
onClose={() => 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() })
}}
/>
</div>
)
}
@@ -544,11 +589,14 @@ function ScopeView({
suggestedName,
sync,
onBound,
onEditConnection,
}: {
connectionId: number
suggestedName: string
sync: ReturnType<typeof useDocSync>
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({
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<button
type="button"
onClick={onEditConnection}
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
<div className="flex gap-2">
<input
value={name}
+6
View File
@@ -177,6 +177,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'متوقفة مؤقتاً: أوقف مسؤول الخادم هذا المزوّد. ستُستأنف المزامنة عند إعادة تشغيله.',
'docsync.binding.reconnect': 'إعادة الاتصال',
'docsync.connection.title': 'الاتصال',
'docsync.connection.hint': 'العنوان وبيانات الدخول التي يستخدمها TREK لهذا المخزن.',
'docsync.connection.edit': 'تعديل الاتصال',
'docsync.connection.remove': 'إزالة الاتصال',
'docsync.connection.confirmRemove':
'ينسى TREK عنوان هذا المخزن وبيانات الدخول إليه، وتتوقف كل مزامنة لهذه الرحلة عبره. تبقى المستندات في TREK وفي المخزن.',
};
export default docsync;
+6
View File
@@ -183,6 +183,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Dayandırılıb: administrator bu provayderi deaktiv edib. O, yenidən aktivləşdirildikdə sinxronlaşdırma davam edəcək.',
'docsync.binding.reconnect': 'Yenidən qoşul',
'docsync.connection.title': 'Bağlantı',
'docsync.connection.hint': 'TREK-in bu anbar üçün istifadə etdiyi ünvan və giriş məlumatları.',
'docsync.connection.edit': 'Bağlantını redaktə et',
'docsync.connection.remove': 'Bağlantını sil',
'docsync.connection.confirmRemove':
'TREK bu anbarın ünvanını və giriş məlumatlarını unudur, bu səyahətin onun üzərindən gedən bütün sinxronlaşdırmaları dayanır. Sənədlər TREK-də və anbarda qalır.',
};
export default docsync;
+6
View File
@@ -180,6 +180,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Pausado: um administrador desativou este provedor. A sincronização continua assim que ele for reativado.',
'docsync.binding.reconnect': 'Voltar a ligar',
'docsync.connection.title': 'Conexão',
'docsync.connection.hint': 'O endereço e o login que o TREK usa para este armazenamento.',
'docsync.connection.edit': 'Editar conexão',
'docsync.connection.remove': 'Remover conexão',
'docsync.connection.confirmRemove':
'O TREK esquece o endereço e o login deste armazenamento, e toda sincronização desta viagem por ele para. Os documentos continuam no TREK e no armazenamento.',
};
export default docsync;
+6
View File
@@ -182,6 +182,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'En pausa: un administrador ha desactivat aquest proveïdor. La sincronització es reprèn quan es torni a activar.',
'docsync.binding.reconnect': 'Torna a connectar',
'docsync.connection.title': 'Connexió',
'docsync.connection.hint': 'L’adreça i l’inici de sessió que TREK fa servir per a aquest emmagatzematge.',
'docsync.connection.edit': 'Edita la connexió',
'docsync.connection.remove': 'Elimina la connexió',
'docsync.connection.confirmRemove':
'TREK oblida l’adreça i l’inici de sessió d’aquest emmagatzematge, i totes les sincronitzacions d’aquest viatge a través seu s’aturen. Els documents es queden a TREK i a l’emmagatzematge.',
};
export default docsync;
+6
View File
@@ -178,6 +178,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Pozastaveno: správce tohoto poskytovatele vypnul. Synchronizace bude pokračovat, jakmile ho znovu zapne.',
'docsync.binding.reconnect': 'Znovu připojit',
'docsync.connection.title': 'Připojení',
'docsync.connection.hint': 'Adresa a přihlášení, které TREK pro toto úložiště používá.',
'docsync.connection.edit': 'Upravit připojení',
'docsync.connection.remove': 'Odebrat připojení',
'docsync.connection.confirmRemove':
'TREK zapomene adresu a přihlášení k tomuto úložišti a veškerá synchronizace této cesty přes něj se zastaví. Dokumenty zůstanou v TREKu i v úložišti.',
};
export default docsync;
+6
View File
@@ -182,6 +182,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Pausiert: Ein Administrator hat diesen Anbieter abgeschaltet. Der Abgleich läuft weiter, sobald er wieder eingeschaltet ist.',
'docsync.binding.reconnect': 'Neu verbinden',
'docsync.connection.title': 'Verbindung',
'docsync.connection.hint': 'Adresse und Anmeldung, mit denen TREK diesen Speicher erreicht.',
'docsync.connection.edit': 'Verbindung bearbeiten',
'docsync.connection.remove': 'Verbindung entfernen',
'docsync.connection.confirmRemove':
'TREK vergisst Adresse und Anmeldung für diesen Speicher, und jeder Abgleich dieser Reise darüber endet. Die Dokumente bleiben in TREK und im Speicher.',
};
export default docsync;
+6
View File
@@ -178,6 +178,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Paused: an administrator has switched this provider off. Syncing resumes once it is back on.',
'docsync.binding.reconnect': 'Reconnect',
'docsync.connection.title': 'Connection',
'docsync.connection.hint': 'The address and login TREK uses for this store.',
'docsync.connection.edit': 'Edit connection',
'docsync.connection.remove': 'Remove connection',
'docsync.connection.confirmRemove':
'TREK forgets the address and login for this store, and every sync of this trip through it stops. Documents stay in TREK and at the store.',
};
export default docsync;
+6
View File
@@ -182,6 +182,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'En pausa: un administrador ha desactivado este proveedor. La sincronización se reanuda en cuanto vuelva a activarse.',
'docsync.binding.reconnect': 'Volver a conectar',
'docsync.connection.title': 'Conexión',
'docsync.connection.hint': 'La dirección y el inicio de sesión que TREK usa para este almacenamiento.',
'docsync.connection.edit': 'Editar conexión',
'docsync.connection.remove': 'Eliminar conexión',
'docsync.connection.confirmRemove':
'TREK olvida la dirección y el inicio de sesión de este almacenamiento, y se detiene toda sincronización de este viaje a través de él. Los documentos se quedan en TREK y en el almacenamiento.',
};
export default docsync;
+6
View File
@@ -178,6 +178,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Peatatud: administraator on selle teenuse välja lülitanud. Sünkroonimine jätkub, kui see uuesti lubatakse.',
'docsync.binding.reconnect': 'Ühenda uuesti',
'docsync.connection.title': 'Ühendus',
'docsync.connection.hint': 'Aadress ja sisselogimine, mida TREK selle hoidla jaoks kasutab.',
'docsync.connection.edit': 'Muuda ühendust',
'docsync.connection.remove': 'Eemalda ühendus',
'docsync.connection.confirmRemove':
'TREK unustab selle hoidla aadressi ja sisselogimise ning kõik selle reisi sünkroonimised selle kaudu peatuvad. Dokumendid jäävad TREKi ja hoidlasse.',
};
export default docsync;
+6
View File
@@ -184,6 +184,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'En pause : un administrateur a désactivé ce fournisseur. La synchronisation reprendra dès qu’il sera réactivé.',
'docsync.binding.reconnect': 'Reconnecter',
'docsync.connection.title': 'Connexion',
'docsync.connection.hint': 'L’adresse et l’identifiant que TREK utilise pour ce stockage.',
'docsync.connection.edit': 'Modifier la connexion',
'docsync.connection.remove': 'Supprimer la connexion',
'docsync.connection.confirmRemove':
'TREK oublie l’adresse et l’identifiant de ce stockage, et toute synchronisation de ce voyage par son biais s’arrête. Les documents restent dans TREK et dans le stockage.',
};
export default docsync;
+7
View File
@@ -185,6 +185,13 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Σε παύση: ένας διαχειριστής απενεργοποίησε αυτόν τον πάροχο. Ο συγχρονισμός θα συνεχιστεί μόλις ενεργοποιηθεί ξανά.',
'docsync.binding.reconnect': 'Επανασύνδεση',
'docsync.connection.title': 'Σύνδεση',
'docsync.connection.hint':
'Η διεύθυνση και τα στοιχεία σύνδεσης που χρησιμοποιεί το TREK για αυτόν τον χώρο αποθήκευσης.',
'docsync.connection.edit': 'Επεξεργασία σύνδεσης',
'docsync.connection.remove': 'Αφαίρεση σύνδεσης',
'docsync.connection.confirmRemove':
'Το TREK ξεχνά τη διεύθυνση και τα στοιχεία σύνδεσης αυτού του χώρου αποθήκευσης και κάθε συγχρονισμός αυτού του ταξιδιού μέσω αυτού σταματά. Τα έγγραφα μένουν στο TREK και στον χώρο αποθήκευσης.',
};
export default docsync;
+6
View File
@@ -183,6 +183,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Szüneteltetve: egy rendszergazda kikapcsolta ezt a szolgáltatót. A szinkronizálás folytatódik, amint újra bekapcsolják.',
'docsync.binding.reconnect': 'Újracsatlakozás',
'docsync.connection.title': 'Kapcsolat',
'docsync.connection.hint': 'A cím és a bejelentkezés, amelyet a TREK ehhez a tárhoz használ.',
'docsync.connection.edit': 'Kapcsolat szerkesztése',
'docsync.connection.remove': 'Kapcsolat eltávolítása',
'docsync.connection.confirmRemove':
'A TREK elfelejti ennek a tárnak a címét és a bejelentkezést, és az utazás minden ezen keresztül futó szinkronizálása leáll. A dokumentumok a TREK-ben és a tárban is megmaradnak.',
};
export default docsync;
+6
View File
@@ -181,6 +181,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Dijeda: administrator menonaktifkan penyedia ini. Sinkronisasi berlanjut setelah penyedia diaktifkan kembali.',
'docsync.binding.reconnect': 'Hubungkan ulang',
'docsync.connection.title': 'Koneksi',
'docsync.connection.hint': 'Alamat dan login yang dipakai TREK untuk penyimpanan ini.',
'docsync.connection.edit': 'Edit koneksi',
'docsync.connection.remove': 'Hapus koneksi',
'docsync.connection.confirmRemove':
'TREK melupakan alamat dan login penyimpanan ini, dan semua sinkronisasi perjalanan ini melaluinya berhenti. Dokumen tetap ada di TREK dan di penyimpanan.',
};
export default docsync;
+6
View File
@@ -182,6 +182,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'In pausa: un amministratore ha disattivato questo provider. La sincronizzazione riprende appena viene riattivato.',
'docsync.binding.reconnect': 'Riconnetti',
'docsync.connection.title': 'Connessione',
'docsync.connection.hint': 'L’indirizzo e l’accesso che TREK usa per questo archivio.',
'docsync.connection.edit': 'Modifica connessione',
'docsync.connection.remove': 'Rimuovi connessione',
'docsync.connection.confirmRemove':
'TREK dimentica l’indirizzo e l’accesso di questo archivio, e ogni sincronizzazione di questo viaggio attraverso di esso si ferma. I documenti restano in TREK e nell’archivio.',
};
export default docsync;
+6
View File
@@ -175,6 +175,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'一時停止中:管理者がこの連携先を無効にしました。再び有効になると同期が再開されます。',
'docsync.binding.reconnect': '再接続',
'docsync.connection.title': '接続',
'docsync.connection.hint': 'TREK がこのストレージに使うアドレスとログイン情報です。',
'docsync.connection.edit': '接続を編集',
'docsync.connection.remove': '接続を削除',
'docsync.connection.confirmRemove':
'TREK はこのストレージのアドレスとログイン情報を削除し、この旅行のこのストレージ経由の同期はすべて止まります。ドキュメントは TREK とストレージの両方に残ります。',
};
export default docsync;
+6
View File
@@ -173,6 +173,12 @@ const docsync: TranslationStrings = {
'docsync.error.unknown_provider': '이 저장소는 이 인스턴스에서 사용할 수 없습니다.',
'docsync.error.provider_disabled': '일시 중지됨: 관리자가 이 저장소를 껐습니다. 다시 켜지면 동기화가 재개됩니다.',
'docsync.binding.reconnect': '다시 연결',
'docsync.connection.title': '연결',
'docsync.connection.hint': 'TREK가 이 저장소에 사용하는 주소와 로그인 정보입니다.',
'docsync.connection.edit': '연결 편집',
'docsync.connection.remove': '연결 삭제',
'docsync.connection.confirmRemove':
'TREK가 이 저장소의 주소와 로그인 정보를 지우고, 이 저장소를 통한 이 여행의 모든 동기화가 중단됩니다. 문서는 TREK와 저장소에 그대로 남습니다.',
};
export default docsync;
+6
View File
@@ -179,6 +179,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Gepauzeerd: een beheerder heeft deze aanbieder uitgeschakeld. De synchronisatie gaat verder zodra hij weer aanstaat.',
'docsync.binding.reconnect': 'Opnieuw verbinden',
'docsync.connection.title': 'Verbinding',
'docsync.connection.hint': 'Het adres en de login die TREK voor deze opslag gebruikt.',
'docsync.connection.edit': 'Verbinding bewerken',
'docsync.connection.remove': 'Verbinding verwijderen',
'docsync.connection.confirmRemove':
'TREK vergeet het adres en de login van deze opslag, en elke synchronisatie van deze reis via deze opslag stopt. De documenten blijven in TREK en in de opslag.',
};
export default docsync;
+6
View File
@@ -183,6 +183,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Wstrzymane: administrator wyłączył tego dostawcę. Synchronizacja zostanie wznowiona, gdy znów go włączy.',
'docsync.binding.reconnect': 'Połącz ponownie',
'docsync.connection.title': 'Połączenie',
'docsync.connection.hint': 'Adres i dane logowania, których TREK używa dla tego magazynu.',
'docsync.connection.edit': 'Edytuj połączenie',
'docsync.connection.remove': 'Usuń połączenie',
'docsync.connection.confirmRemove':
'TREK zapomina adres i dane logowania tego magazynu, a każda synchronizacja tej podróży przez niego się zatrzymuje. Dokumenty zostają w TREK i w magazynie.',
};
export default docsync;
+6
View File
@@ -184,6 +184,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Приостановлено: администратор отключил этого провайдера. Синхронизация возобновится, как только его снова включат.',
'docsync.binding.reconnect': 'Подключить заново',
'docsync.connection.title': 'Подключение',
'docsync.connection.hint': 'Адрес и данные входа, которые TREK использует для этого хранилища.',
'docsync.connection.edit': 'Изменить подключение',
'docsync.connection.remove': 'Удалить подключение',
'docsync.connection.confirmRemove':
'TREK забудет адрес и данные входа для этого хранилища, и вся синхронизация этой поездки через него остановится. Документы останутся в TREK и в хранилище.',
};
export default docsync;
+6
View File
@@ -179,6 +179,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Pozastavené: správca tohto poskytovateľa vypol. Synchronizácia bude pokračovať, hneď ako ho znova zapne.',
'docsync.binding.reconnect': 'Znova pripojiť',
'docsync.connection.title': 'Pripojenie',
'docsync.connection.hint': 'Adresa a prihlásenie, ktoré TREK pre toto úložisko používa.',
'docsync.connection.edit': 'Upraviť pripojenie',
'docsync.connection.remove': 'Odstrániť pripojenie',
'docsync.connection.confirmRemove':
'TREK zabudne adresu a prihlásenie k tomuto úložisku a každá synchronizácia tejto cesty cez neho sa zastaví. Dokumenty zostanú v TREKu aj v úložisku.',
};
export default docsync;
+6
View File
@@ -176,6 +176,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Pausad: en administratör har stängt av den här leverantören. Synkroniseringen fortsätter när den slås på igen.',
'docsync.binding.reconnect': 'Anslut igen',
'docsync.connection.title': 'Anslutning',
'docsync.connection.hint': 'Adressen och inloggningen som TREK använder för den här lagringen.',
'docsync.connection.edit': 'Redigera anslutning',
'docsync.connection.remove': 'Ta bort anslutning',
'docsync.connection.confirmRemove':
'TREK glömmer adressen och inloggningen för den här lagringen, och all synkronisering av resan via den stoppas. Dokumenten finns kvar i TREK och i lagringen.',
};
export default docsync;
+6
View File
@@ -173,6 +173,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'หยุดชั่วคราว: ผู้ดูแลระบบปิดผู้ให้บริการนี้ การซิงค์จะกลับมาทำงานเมื่อเปิดอีกครั้ง',
'docsync.binding.reconnect': 'เชื่อมต่อใหม่',
'docsync.connection.title': 'การเชื่อมต่อ',
'docsync.connection.hint': 'ที่อยู่และข้อมูลเข้าสู่ระบบที่ TREK ใช้กับที่เก็บนี้',
'docsync.connection.edit': 'แก้ไขการเชื่อมต่อ',
'docsync.connection.remove': 'ลบการเชื่อมต่อ',
'docsync.connection.confirmRemove':
'TREK จะลืมที่อยู่และข้อมูลเข้าสู่ระบบของที่เก็บนี้ และการซิงก์ทั้งหมดของทริปนี้ผ่านที่เก็บนี้จะหยุดลง เอกสารยังอยู่ใน TREK และในที่เก็บ',
};
export default docsync;
+6
View File
@@ -180,6 +180,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Duraklatıldı: bir yönetici bu sağlayıcıyı kapattı. Yeniden açıldığında eşitleme kaldığı yerden devam eder.',
'docsync.binding.reconnect': 'Yeniden bağlan',
'docsync.connection.title': 'Bağlantı',
'docsync.connection.hint': 'TREK’in bu depo için kullandığı adres ve oturum bilgileri.',
'docsync.connection.edit': 'Bağlantıyı düzenle',
'docsync.connection.remove': 'Bağlantıyı kaldır',
'docsync.connection.confirmRemove':
'TREK bu deponun adresini ve oturum bilgilerini unutur, bu seyahatin bu depo üzerinden yapılan tüm eşitlemeleri durur. Belgeler TREK’te ve depoda kalır.',
};
export default docsync;
+6
View File
@@ -182,6 +182,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Призупинено: адміністратор вимкнув цього постачальника. Синхронізація відновиться, щойно його знову ввімкнуть.',
'docsync.binding.reconnect': 'Підключити знову',
'docsync.connection.title': 'Підключення',
'docsync.connection.hint': 'Адреса й дані входу, які TREK використовує для цього сховища.',
'docsync.connection.edit': 'Змінити підключення',
'docsync.connection.remove': 'Видалити підключення',
'docsync.connection.confirmRemove':
'TREK забуде адресу й дані входу для цього сховища, і вся синхронізація цієї подорожі через нього зупиниться. Документи залишаться в TREK і в сховищі.',
};
export default docsync;
+6
View File
@@ -178,6 +178,12 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'Đã tạm dừng: quản trị viên đã tắt nhà cung cấp này. Việc đồng bộ sẽ tiếp tục khi nó được bật lại.',
'docsync.binding.reconnect': 'Kết nối lại',
'docsync.connection.title': 'Kết nối',
'docsync.connection.hint': 'Địa chỉ và thông tin đăng nhập TREK dùng cho kho lưu trữ này.',
'docsync.connection.edit': 'Sửa kết nối',
'docsync.connection.remove': 'Xóa kết nối',
'docsync.connection.confirmRemove':
'TREK quên địa chỉ và thông tin đăng nhập của kho lưu trữ này, và mọi đồng bộ của chuyến đi này qua kho đó đều dừng. Tài liệu vẫn còn trong TREK và trong kho lưu trữ.',
};
export default docsync;
+6
View File
@@ -168,6 +168,12 @@ const docsync: TranslationStrings = {
'docsync.error.unknown_provider': '這個提供者在此執行個體上無法使用。',
'docsync.error.provider_disabled': '已暫停:管理員已停用此提供者。重新啟用後會繼續同步。',
'docsync.binding.reconnect': '重新連線',
'docsync.connection.title': '連線',
'docsync.connection.hint': 'TREK 用於此儲存空間的位址與登入資訊。',
'docsync.connection.edit': '編輯連線',
'docsync.connection.remove': '移除連線',
'docsync.connection.confirmRemove':
'TREK 會忘記此儲存空間的位址與登入資訊,此行程經由它的所有同步都會停止。文件仍保留在 TREK 與儲存空間中。',
};
export default docsync;
+6
View File
@@ -167,6 +167,12 @@ const docsync: TranslationStrings = {
'docsync.error.unknown_provider': '此实例不提供该文档服务。',
'docsync.error.provider_disabled': '已暂停:管理员已关闭此文档服务。重新启用后将继续同步。',
'docsync.binding.reconnect': '重新连接',
'docsync.connection.title': '连接',
'docsync.connection.hint': 'TREK 用于此存储的地址和登录信息。',
'docsync.connection.edit': '编辑连接',
'docsync.connection.remove': '移除连接',
'docsync.connection.confirmRemove':
'TREK 会忘记此存储的地址和登录信息,此行程经由它的所有同步都会停止。文档仍保留在 TREK 和存储中。',
};
export default docsync;