feat(documents): pick documents from Paperless-ngx or Papra in the vault and wherever documents are linked

A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:26:18 +02:00
1 parent 92f2cc729f
commit d109d67889
21 files changed
+2200 -20

No files matched your search

+12 -7
View File
@@ -67,6 +67,7 @@ import { DocumentShareSheet } from "./document-share-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
@@ -876,13 +877,17 @@ export function DocumentsView() {
description={t("documents.subtitle")}
actions={
canManageDocuments ? (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
<div className="flex flex-wrap items-center gap-2">
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
</div>
) : null
}
/>
@@ -32,6 +32,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
@@ -167,6 +168,9 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
{t("documents.episodeCard.upload")}
</Link>
</Button>
<ImportFromSourceButton
link={{ kind: "conditionEpisode", id: episodeId }}
/>
</>
) : null}
{hasMore ? (
@@ -0,0 +1,230 @@
/**
* The document picker's client gates (#1038), pinned via static renders:
*
* - the import button exists only for a person in their own record with a
* usable connection; a delegate, an unset operator list, no connection, or
* a connection whose origin was removed leave no button;
* - its label names the one connected system, or stays generic for two;
* - the settings card renders nothing unless the operator enabled the
* picker, lists the allowed origins, and never puts a token in the markup;
* - every error code the routes emit has its own sentence, and an unknown
* one falls back to the generic sentence rather than a raw key.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
let modules: Record<string, boolean> = { inboundDocuments: true };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules },
isLoading: false,
isAuthenticated: true,
}),
}));
let shared = false;
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: shared,
canManageDomain: () => !shared,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return { ...actual, apiGet: () => new Promise(() => {}) };
});
import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
import en from "../../../../../messages/en.json";
import { ApiError } from "@/lib/api/api-fetch";
import {
DOCUMENT_SOURCE_ERROR_CODES,
type DocumentSourcesStatusDto,
} from "@/lib/documents/sources/types";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ImportFromSourceButton } from "../import-from-source-button";
import { mergeIds } from "../merge-ids";
import { errorCodeOf, sourceErrorMessage } from "../source-errors";
const PAPERLESS = {
system: "PAPERLESS" as const,
baseUrl: "http://paperless.lan:8000",
organizationId: null,
hasToken: true as const,
lastVerifiedAt: "2026-09-20T10:00:00.000Z",
originAllowed: true,
};
const PAPRA = {
...PAPERLESS,
system: "PAPRA" as const,
baseUrl: "https://papra.example.com",
organizationId: "org_1",
};
function render(node: React.ReactNode, status?: DocumentSourcesStatusDto) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (status)
queryClient.setQueryData(queryKeys.documentSourcesStatus(), status);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
const on = (connections: DocumentSourcesStatusDto["connections"]) => ({
available: true,
allowedOrigins: ["http://paperless.lan:8000", "https://papra.example.com"],
connections,
});
describe("<ImportFromSourceButton>", () => {
it("names the one connected system", () => {
const html = render(<ImportFromSourceButton />, on([PAPERLESS]));
expect(html).toContain('data-slot="document-source-open"');
expect(html).toContain("Import from Paperless-ngx");
});
it("stays generic with two", () => {
const html = render(<ImportFromSourceButton />, on([PAPERLESS, PAPRA]));
expect(html).toContain("Import from archive");
});
it("renders nothing without a usable connection", () => {
expect(render(<ImportFromSourceButton />, on([]))).toBe("");
expect(
render(
<ImportFromSourceButton />,
on([{ ...PAPERLESS, originAllowed: false }]),
),
).toBe("");
expect(
render(<ImportFromSourceButton />, {
available: false,
allowedOrigins: [],
connections: [],
}),
).toBe("");
});
it("renders nothing for a delegate, even with the owner's data cached", () => {
shared = true;
try {
expect(render(<ImportFromSourceButton />, on([PAPERLESS]))).toBe("");
} finally {
shared = false;
}
});
it("renders nothing with the documents module off", () => {
modules = { inboundDocuments: false };
try {
expect(render(<ImportFromSourceButton />, on([PAPERLESS]))).toBe("");
} finally {
modules = { inboundDocuments: true };
}
});
});
describe("<DocumentSourcesCard>", () => {
it("renders nothing when the operator has not enabled the picker", () => {
expect(
render(<DocumentSourcesCard />, {
available: false,
allowedOrigins: [],
connections: [],
}),
).toBe("");
expect(render(<DocumentSourcesCard />)).toBe("");
});
it("renders nothing for a delegate", () => {
shared = true;
try {
expect(render(<DocumentSourcesCard />, on([PAPERLESS]))).toBe("");
} finally {
shared = false;
}
});
it("shows the form, the allowed origins and a saved-token placeholder, never a token", () => {
const html = render(<DocumentSourcesCard />, on([PAPERLESS]));
expect(html).toContain('data-testid="document-sources-card"');
expect(html).toContain("Document archives");
expect(html).toContain(
"Allowed on this server: http://paperless.lan:8000, https://papra.example.com",
);
expect(html).toContain("Saved. Leave empty to keep it.");
expect(html).toContain('value="http://paperless.lan:8000"');
// The password field is empty: the server never sends the token back.
expect(html).toMatch(/id="document-source-token-paperless"[^>]*value=""/);
expect(html).toContain("Disconnect");
});
it("offers no disconnect or test for a system that is not connected", () => {
const html = render(<DocumentSourcesCard />, on([]));
expect(html).toContain("Not connected");
expect(html).not.toContain("Disconnect");
expect(html).toContain("Save and test");
});
});
describe("source error sentences", () => {
const t = (key: string) => {
let node: unknown = en;
for (const part of key.split(".")) {
node = (node as Record<string, unknown> | undefined)?.[part];
}
return typeof node === "string" ? node : key;
};
const generic = t("documents.sourcePicker.errors.generic");
it("has its own sentence for every code the routes emit", () => {
const codes = [
...DOCUMENT_SOURCE_ERROR_CODES,
"documents.sources.browserOnly",
"documents.sources.invalidAddress",
"documents.sources.organizationRequired",
"documents.sources.tokenRequired",
"documents.inbound.fileTooLarge",
"documents.inbound.quotaExceeded",
"documents.inbound.fileType",
"documents.inbound.rateLimited",
"documents.inbound.uploadBusy",
"module.disabled",
];
const sentences = new Set<string>();
for (const code of codes) {
const sentence = sourceErrorMessage(t, code);
expect(sentence, code).not.toBe(generic);
expect(sentence, code).not.toContain("documents.sourcePicker");
sentences.add(sentence);
}
expect(sentences.size).toBe(codes.length);
expect(sourceErrorMessage(t, "something.else")).toBe(generic);
expect(sourceErrorMessage(t, undefined)).toBe(generic);
});
it("reads the code off a failed request", () => {
expect(
errorCodeOf(
new ApiError("x", 502, { errorCode: "documents.sources.authRefused" }),
),
).toBe("documents.sources.authRefused");
expect(errorCodeOf(new Error("x"))).toBeUndefined();
});
});
describe("mergeIds", () => {
it("adds imported ids to a form's selection once, in order", () => {
expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
});
});
@@ -0,0 +1,654 @@
"use client";
/**
* The document picker (#1038): search a connected Paperless-ngx or Papra by
* document name, narrow by tag and date, tick the documents to bring over,
* import them.
*
* Name first, because that is how the person looks for a letter: the search
* field opens focused and debounces into the source's own name search. Tag and
* date are filters beside it, not the entry point. Each result says what
* HealthLog already holds under that source key: already imported, deleted
* here (not offered again: a deletion was a decision), or new.
*
* The import runs one document per request, in order, so every row can show
* its own progress and result, and one failure does not hide the others. A
* refusal that will answer every following document the same way (the hourly
* upload allowance, a full vault, a refused token) stops the run and says so.
*
* Where it was opened from decides what an import does besides storing:
*
* - `link`: the record already exists (a condition's page), so the server
* files the document against it as part of the import;
* - `onImported`: a form that owns its selection (a visit, a vaccination)
* gets the new ids and adds them itself. Linking on the server there would
* be undone by the form's own save, which replaces its links.
*
* With either, a document already in HealthLog can be picked too: it is not
* stored twice, it is linked or handed to the form.
*/
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
Check,
CircleAlert,
CircleCheck,
Loader2,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { TagChip } from "@/components/ui/tag-chip";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import {
DOCUMENT_PICKER_MAX_SELECTION,
slugForSystem,
type DocumentImportOutcome,
type DocumentPickerLinkKind,
type DocumentPickerSystem,
type DocumentSourceImportDto,
type DocumentSourceResultDto,
type DocumentSourceSearchDto,
type DocumentSourceTagDto,
} from "@/lib/documents/sources/types";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { formatBytes } from "../vault-utils";
import {
errorCodeOf,
isStoppingError,
sourceErrorMessage,
} from "./source-errors";
import { systemName } from "./use-document-sources";
export interface DocumentPickerLink {
kind: DocumentPickerLinkKind;
id: string;
}
export interface DocumentSourcePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Connected systems, in the order the switch shows them. */
systems: DocumentPickerSystem[];
/** File the imported documents against this existing record. */
link?: DocumentPickerLink;
/** Document type for everything imported (default OTHER). */
kind?: InboundDocumentKindValue;
/** Hand the vault ids to a form that links them on its own save. */
onImported?: (documentIds: string[]) => void;
}
type RowRun =
| { state: "waiting" }
| { state: "working" }
| { state: DocumentImportOutcome; linked: boolean }
| { state: "failed"; message: string };
const SEARCH_DEBOUNCE_MS = 300;
const IMPORT_TIMEOUT_MS = 120_000;
export function DocumentSourcePicker({
open,
onOpenChange,
systems,
link,
kind,
onImported,
}: DocumentSourcePickerProps) {
const { t, locale } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [system, setSystem] = useState<DocumentPickerSystem | null>(null);
const active =
system && systems.includes(system) ? system : (systems[0] ?? null);
const slug = active ? slugForSystem(active) : null;
const [searchDraft, setSearchDraft] = useState("");
const [q, setQ] = useState("");
const [tag, setTag] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [runs, setRuns] = useState<Map<string, RowRun>>(new Map());
const [running, setRunning] = useState(false);
const [finished, setFinished] = useState(false);
const [stopMessage, setStopMessage] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(
() => setQ(value.trim()),
SEARCH_DEBOUNCE_MS,
);
};
const reset = () => {
setSearchDraft("");
setQ("");
setTag("");
setFrom("");
setTo("");
setSelected(new Map());
setRuns(new Map());
setFinished(false);
setStopMessage(null);
};
const switchSystem = (next: DocumentPickerSystem) => {
if (next === active || running) return;
reset();
setSystem(next);
};
const tags = useQuery({
queryKey: queryKeys.documentSourceTags(slug ?? ""),
enabled: open && slug !== null,
queryFn: () =>
apiGet<{ tags: DocumentSourceTagDto[] }>(
`/api/documents/sources/${slug}/tags`,
),
staleTime: 5 * 60_000,
});
const facets = {
q,
...(tag ? { tag } : {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
};
const rangeInvalid = Boolean(from && to && from > to);
const search = useInfiniteQuery({
queryKey: queryKeys.documentSourceSearch(slug ?? "", facets),
enabled: open && slug !== null && !rangeInvalid,
initialPageParam: 1,
queryFn: ({ pageParam }) => {
const sp = new URLSearchParams({ page: String(pageParam) });
if (q) sp.set("q", q);
if (tag) sp.set("tag", tag);
if (from) sp.set("from", from);
if (to) sp.set("to", to);
return apiGet<DocumentSourceSearchDto>(
`/api/documents/sources/${slug}/search?${sp.toString()}`,
);
},
getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined),
staleTime: 30_000,
});
const results = useMemo(() => {
const seen = new Set<string>();
const out: DocumentSourceResultDto[] = [];
for (const page of search.data?.pages ?? []) {
for (const row of page.results) {
if (seen.has(row.sourceId)) continue;
seen.add(row.sourceId);
out.push(row);
}
}
return out;
}, [search.data]);
// An already imported document is worth picking only when the pick does
// something with it: link it, or hand it to the form.
const canTakeImported = Boolean(link || onImported);
const selectable = (row: DocumentSourceResultDto) =>
row.state === "new" || (row.state === "imported" && canTakeImported);
const toggle = (row: DocumentSourceResultDto) => {
if (running || finished || !selectable(row)) return;
setSelected((prev) => {
const next = new Map(prev);
if (next.has(row.sourceId)) next.delete(row.sourceId);
else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) {
next.set(row.sourceId, row.title);
}
return next;
});
};
async function runImport() {
if (!slug || selected.size === 0) return;
setRunning(true);
setStopMessage(null);
const order = [...selected.keys()];
setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
const handed: string[] = [];
const mark = (id: string, run: RowRun) =>
setRuns((prev) => new Map(prev).set(id, run));
for (const sourceId of order) {
mark(sourceId, { state: "working" });
try {
const result = await apiPost<DocumentSourceImportDto>(
`/api/documents/sources/${slug}/import`,
{
sourceId,
...(kind ? { kind } : {}),
...(link ? { link } : {}),
},
// The server may take up to a minute to fetch a large file from
// the source; the default 15 s client timeout would abandon it.
{ signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) },
);
mark(sourceId, { state: result.outcome, linked: result.linked });
if (result.outcome !== "deleted" && result.documentId) {
handed.push(result.documentId);
}
} catch (err) {
const code = errorCodeOf(err);
const message = sourceErrorMessage(t, code);
mark(sourceId, { state: "failed", message });
if (isStoppingError(code)) {
setStopMessage(message);
break;
}
}
}
setRunning(false);
setFinished(true);
setSelected(new Map());
// The vault, its pickers and the record's own lists read documents
// through the `["documents"]` prefix; the picker's states through its own.
void invalidateKeys(queryClient, [
queryKeys.documents(),
queryKeys.documentSources(),
]);
if (handed.length > 0) onImported?.(handed);
}
const counts = useMemo(() => {
let imported = 0;
let present = 0;
let failed = 0;
for (const run of runs.values()) {
if (run.state === "imported") imported += 1;
else if (run.state === "duplicate" || run.state === "deleted")
present += 1;
else if (run.state === "failed") failed += 1;
}
return { imported, present, failed };
}, [runs]);
const name = active ? systemName(active) : "";
const hasFilters = Boolean(tag || from || to);
const footer = (
<div className="flex w-full items-center justify-between gap-3">
<p
className="text-muted-foreground min-w-0 text-xs"
data-slot="document-source-selection"
>
{finished
? t("documents.sourcePicker.summary", counts)
: running
? t("documents.sourcePicker.importing")
: t("documents.sourcePicker.selectedCount", {
count: selected.size,
max: DOCUMENT_PICKER_MAX_SELECTION,
})}
</p>
{finished ? (
<Button
type="button"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => onOpenChange(false)}
>
{t("documents.sourcePicker.done")}
</Button>
) : (
<Button
type="button"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={running || selected.size === 0}
onClick={() => void runImport()}
data-slot="document-source-import"
>
{running ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.sourcePicker.importAction", { count: selected.size })}
</Button>
)}
</div>
);
return (
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// A run in progress finishes before the sheet can close; its rows
// are the only place the outcome is shown.
if (!next && running) return;
if (!next) reset();
onOpenChange(next);
}}
title={t("documents.sourcePicker.title", { name })}
description={t("documents.sourcePicker.description")}
contentWidth="lg"
footer={footer}
>
<div className="space-y-3" data-slot="document-source-picker">
{systems.length > 1 ? (
<div
role="radiogroup"
aria-label={t("documents.sourcePicker.sourceLabel")}
className="bg-muted inline-flex rounded-md p-1"
>
{systems.map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={option === active}
disabled={running}
onClick={() => switchSystem(option)}
className={cn(
"min-h-9 rounded-sm px-3 text-sm font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
option === active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{systemName(option)}
</button>
))}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="document-source-search">
{t("documents.sourcePicker.searchLabel")}
</Label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="document-source-search"
type="search"
autoFocus
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.sourcePicker.searchPlaceholder")}
className="pl-9"
maxLength={200}
autoComplete="off"
data-slot="document-source-search"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="document-source-tag">
{t("documents.sourcePicker.tagLabel")}
</Label>
<NativeSelect
id="document-source-tag"
value={tag}
onChange={(e) => setTag(e.target.value)}
disabled={tags.isPending && !tags.isError}
>
<option value="">{t("documents.sourcePicker.tagAny")}</option>
{(tags.data?.tags ?? []).map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-source-from">
{t("documents.sourcePicker.fromLabel")}
</Label>
<DateField
id="document-source-from"
value={from}
onChange={setFrom}
max={to || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-source-to">
{t("documents.sourcePicker.toLabel")}
</Label>
<DateField
id="document-source-to"
value={to}
onChange={setTo}
min={from || undefined}
/>
</div>
</div>
{hasFilters ? (
<button
type="button"
onClick={() => {
setTag("");
setFrom("");
setTo("");
}}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="size-3.5" aria-hidden />
{t("documents.sourcePicker.clearFilters")}
</button>
) : null}
{stopMessage ? (
<p
role="alert"
className="text-destructive text-sm"
data-slot="document-source-stopped"
>
{t("documents.sourcePicker.stopped", { reason: stopMessage })}
</p>
) : null}
{rangeInvalid ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.sourcePicker.rangeInvalid")}
</p>
) : search.isPending ? (
<div className="space-y-2" data-slot="document-source-loading">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : search.isError ? (
<QueryErrorRow
message={sourceErrorMessage(t, errorCodeOf(search.error))}
onRetry={() => void search.refetch()}
slot="document-source-error"
/>
) : results.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{q || hasFilters
? t("documents.sourcePicker.noMatch")
: t("documents.sourcePicker.empty", { name })}
</p>
) : (
<ul
className="max-h-[45vh] space-y-1.5 overflow-y-auto overscroll-contain"
data-slot="document-source-results"
>
{results.map((row) => {
const on = selected.has(row.sourceId);
const run = runs.get(row.sourceId);
const allowed = selectable(row);
const meta = [
row.date ? format.date(`${row.date}T12:00:00.000Z`) : null,
row.sizeBytes !== null
? formatBytes(row.sizeBytes, locale)
: null,
]
.filter(Boolean)
.join(" · ");
return (
<li key={row.sourceId}>
<button
type="button"
aria-pressed={on}
aria-disabled={!allowed || running || finished}
onClick={() => toggle(row)}
data-slot="document-source-result"
data-state={row.state}
className={cn(
"border-border flex min-h-11 w-full items-start gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
allowed && !running && !finished && "hover:bg-muted/50",
on && "border-primary/40 bg-primary/5",
!allowed && "opacity-50",
)}
>
<Check
className={cn(
"text-primary mt-0.5 size-4 shrink-0",
on ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate text-sm font-medium">
{row.title}
</span>
{meta ? (
<span className="text-muted-foreground block text-xs">
{meta}
</span>
) : null}
{row.tags.length > 0 ? (
<span className="flex flex-wrap gap-1">
{row.tags.slice(0, 6).map((name) => (
<TagChip key={name}>{name}</TagChip>
))}
</span>
) : null}
{run ? <RunLine run={run} /> : null}
</span>
{row.state !== "new" && !run ? (
<Badge variant="outline" className="shrink-0">
{row.state === "imported"
? t("documents.sourcePicker.stateImported")
: t("documents.sourcePicker.stateDeleted")}
</Badge>
) : null}
</button>
</li>
);
})}
</ul>
)}
{search.hasNextPage && !search.isError ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9"
disabled={search.isFetchingNextPage}
onClick={() => void search.fetchNextPage()}
>
{search.isFetchingNextPage ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.sourcePicker.loadMore")}
</Button>
) : null}
</div>
</ResponsiveSheet>
);
}
/** One row's progress or result, under its title. */
function RunLine({ run }: { run: RowRun }) {
const { t } = useTranslations();
switch (run.state) {
case "waiting":
return (
<span className="text-muted-foreground block text-xs">
{t("documents.sourcePicker.result.waiting")}
</span>
);
case "working":
return (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.sourcePicker.result.working")}
</span>
);
case "failed":
return (
<span
className="text-destructive flex items-start gap-1.5 text-xs"
data-slot="document-source-run-failed"
>
<CircleAlert className="mt-px size-3.5 shrink-0" aria-hidden />
{run.message}
</span>
);
case "deleted":
return (
<span className="text-muted-foreground block text-xs">
{t("documents.sourcePicker.result.deleted")}
</span>
);
case "imported":
case "duplicate":
return (
<span
className="text-success flex items-center gap-1.5 text-xs"
data-slot="document-source-run-done"
>
<CircleCheck className="size-3.5 shrink-0" aria-hidden />
{run.state === "imported"
? run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported")
: run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate")}
</span>
);
}
}
@@ -0,0 +1,73 @@
"use client";
/**
* "Import from Paperless-ngx / Papra": the button that opens the document
* picker (#1038), for every place a document can be added or linked.
*
* Renders nothing unless the person, in their own record, has at least one
* usable connection: no connection, a delegate or guardian, the module off, or
* the operator's list unset all leave no trace, because a button that can only
* lead to a refusal advertises a feature that is not there. Connecting happens
* in Settings, not here.
*/
import { Import } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import {
DocumentSourcePicker,
type DocumentPickerLink,
} from "./document-source-picker";
import { systemName, useDocumentSourcesStatus } from "./use-document-sources";
export function ImportFromSourceButton({
link,
kind,
onImported,
className,
}: {
link?: DocumentPickerLink;
kind?: InboundDocumentKindValue;
onImported?: (documentIds: string[]) => void;
className?: string;
}) {
const { t } = useTranslations();
const { connected } = useDocumentSourcesStatus();
const [open, setOpen] = useState(false);
if (connected.length === 0) return null;
const label =
connected.length === 1
? t("documents.sourcePicker.openNamed", {
name: systemName(connected[0]),
})
: t("documents.sourcePicker.open");
return (
<>
<Button
type="button"
variant="outline"
size="sm"
className={className}
onClick={() => setOpen(true)}
data-slot="document-source-open"
>
<Import className="size-4" aria-hidden />
{label}
</Button>
<DocumentSourcePicker
open={open}
onOpenChange={setOpen}
systems={connected}
link={link}
kind={kind}
onImported={onImported}
/>
</>
);
}
@@ -0,0 +1,6 @@
/** Add imported ids to a form's selection, in order, never twice. */
export function mergeIds(selected: string[], added: string[]): string[] {
const merged = [...selected];
for (const id of added) if (!merged.includes(id)) merged.push(id);
return merged;
}
@@ -0,0 +1,81 @@
/**
* The document picker's error codes (#1038), as sentences. One place for the
* sheet and the settings card, so the same refusal reads the same on both.
* Every branch names its key literally so the i18n coverage guard can see it.
*/
import { ApiError } from "@/lib/api/api-fetch";
type T = (key: string, params?: Record<string, string | number>) => string;
/** The `meta.errorCode` a failed request carried, if any. */
export function errorCodeOf(err: unknown): string | undefined {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (typeof code === "string") return code;
}
return undefined;
}
/** Is this a refusal that will not change on the next document? */
export function isStoppingError(code: string | undefined): boolean {
return (
code === "documents.inbound.rateLimited" ||
code === "documents.inbound.quotaExceeded" ||
code === "documents.sources.authRefused" ||
code === "documents.sources.unreachable" ||
code === "documents.sources.originNotAllowed" ||
code === "documents.sources.notConnected" ||
code === "documents.sources.unavailable" ||
code === "documents.sources.browserOnly" ||
code === "documents.sources.linkTargetNotFound"
);
}
export function sourceErrorMessage(t: T, code: string | undefined): string {
switch (code) {
case "documents.sources.unavailable":
return t("documents.sourcePicker.errors.unavailable");
case "documents.sources.notConnected":
return t("documents.sourcePicker.errors.notConnected");
case "documents.sources.originNotAllowed":
return t("documents.sourcePicker.errors.originNotAllowed");
case "documents.sources.unreachable":
return t("documents.sourcePicker.errors.unreachable");
case "documents.sources.redirected":
return t("documents.sourcePicker.errors.redirected");
case "documents.sources.authRefused":
return t("documents.sourcePicker.errors.authRefused");
case "documents.sources.versionTooOld":
return t("documents.sourcePicker.errors.versionTooOld");
case "documents.sources.badResponse":
return t("documents.sourcePicker.errors.badResponse");
case "documents.sources.notFound":
return t("documents.sourcePicker.errors.notFound");
case "documents.sources.linkTargetNotFound":
return t("documents.sourcePicker.errors.linkTargetNotFound");
case "documents.sources.rateLimited":
return t("documents.sourcePicker.errors.rateLimited");
case "documents.sources.browserOnly":
return t("documents.sourcePicker.errors.browserOnly");
case "documents.sources.invalidAddress":
return t("documents.sourcePicker.errors.invalidAddress");
case "documents.sources.organizationRequired":
return t("documents.sourcePicker.errors.organizationRequired");
case "documents.sources.tokenRequired":
return t("documents.sourcePicker.errors.tokenRequired");
case "documents.inbound.fileTooLarge":
return t("documents.sourcePicker.errors.fileTooLarge");
case "documents.inbound.quotaExceeded":
return t("documents.sourcePicker.errors.quotaExceeded");
case "documents.inbound.fileType":
return t("documents.sourcePicker.errors.fileType");
case "documents.inbound.rateLimited":
return t("documents.sourcePicker.errors.uploadLimit");
case "documents.inbound.uploadBusy":
return t("documents.sourcePicker.errors.uploadBusy");
case "module.disabled":
return t("documents.sourcePicker.errors.moduleDisabled");
default:
return t("documents.sourcePicker.errors.generic");
}
}
@@ -0,0 +1,51 @@
"use client";
/**
* The document picker's availability for the person on this screen (#1038).
*
* Reads `GET /api/documents/sources` only when it can matter: the documents
* module is on and the person is in their own record with the right to manage
* documents. A delegate or guardian never asks (the route would refuse them
* anyway), so the import buttons and the settings card render nothing for
* them. `connected` is the list of systems with a usable connection, in a
* stable order; an entry whose origin the operator removed is left out.
*/
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import type {
DocumentPickerSystem,
DocumentSourcesStatusDto,
} from "@/lib/documents/sources/types";
import { queryKeys } from "@/lib/query-keys";
export function useDocumentSourcesStatus() {
const { user } = useAuth();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const eligible =
user?.modules?.inboundDocuments === true &&
!inSharedRecord &&
canManageDomain("documents");
const query = useQuery({
queryKey: queryKeys.documentSourcesStatus(),
enabled: eligible,
queryFn: () => apiGet<DocumentSourcesStatusDto>("/api/documents/sources"),
staleTime: 60_000,
});
const connected: DocumentPickerSystem[] = eligible
? (query.data?.connections ?? [])
.filter((c) => c.originAllowed)
.map((c) => c.system)
: [];
return { eligible, query, connected };
}
/** Display name of a system; product names, not translated. */
export function systemName(system: DocumentPickerSystem): string {
return system === "PAPERLESS" ? "Paperless-ngx" : "Papra";
}
@@ -32,6 +32,8 @@ import {
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
export const PICKER_FETCH_LIMIT = 200;
@@ -152,6 +154,17 @@ export function EncounterLinkPickers({
options={documents.options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
extraAction={
// The form owns its selection and replaces the visit's links on
// save, so imported documents join the selection rather than
// being linked on the server.
<ImportFromSourceButton
className="min-h-9"
onImported={(ids) =>
onChange({ documentIds: mergeIds(documentIds, ids) })
}
/>
}
/>
) : null}
+27 -13
View File
@@ -31,7 +31,7 @@
* carries the option's date, so two doses of one vaccine read as two
* different records.
*/
import { useMemo, useState } from "react";
import { useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -164,6 +164,7 @@ export function EntityLinkPicker({
error = false,
errorLabel,
onRetry,
extraAction,
}: {
icon: LucideIcon;
title: string;
@@ -183,6 +184,13 @@ export function EntityLinkPicker({
error?: boolean;
errorLabel?: string;
onRetry?: () => void;
/**
* A second way to add, beside the add button: the documents pickers put
* "Import from Paperless-ngx / Papra" here. Shown even when there is
* nothing to link yet, because an empty vault is exactly when importing is
* the way forward. The element renders nothing when it has nothing to offer.
*/
extraAction?: ReactNode;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -238,7 +246,10 @@ export function EntityLinkPicker({
slot={`${slot}-error`}
/>
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
<div className="space-y-2">
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
{extraAction}
</div>
) : (
<div className="space-y-2">
{selectedChips.length > 0 ? (
@@ -312,17 +323,20 @@ export function EntityLinkPicker({
</p>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
data-slot={`${slot}-add`}
onClick={() => setOpen(true)}
>
<Plus className="size-4" aria-hidden />
{t("links.picker.add")}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
data-slot={`${slot}-add`}
onClick={() => setOpen(true)}
>
<Plus className="size-4" aria-hidden />
{t("links.picker.add")}
</Button>
{extraAction}
</div>
</div>
)}
@@ -37,6 +37,7 @@ import { ConnectionsPanel } from "@/components/settings/integrations/connections
import { SectionHeading } from "@/components/ui/section-heading";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
import { Button } from "@/components/ui/button";
export {
@@ -77,6 +78,11 @@ export function IntegrationsSection({
under the same Connections group rather than a new labelled
section that could paint an orphaned heading over nothing. */}
<NutrientIntakeCard />
{/* v1.39.3 (#1038) — Paperless-ngx / Papra for the document picker.
Renders nothing unless the operator listed an origin in
DOCUMENT_SOURCE_ORIGINS and the person is in their own record. */}
<DocumentSourcesCard />
</section>
{/* Delivery channels ("where it's delivered"). The existing channels
@@ -0,0 +1,415 @@
"use client";
/**
* Settings → Integrations → Document archives (#1038): connect Paperless-ngx or
* Papra so the document picker can search it.
*
* Renders nothing unless the operator listed at least one origin in
* `DOCUMENT_SOURCE_ORIGINS`, the documents module is on, and the person is in
* their own record: a delegate never manages the owner's connection, and a
* card that can only be refused advertises a feature that is not there.
*
* One form per system behind a switch. Save tests the connection before it is
* stored and says why when it fails; the token field never shows the stored
* token (the server never returns it), only that one is saved, and leaving it
* empty keeps it. Results sit above the action row; nothing follows it
* (UI standards §12).
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Archive, CheckCircle2, Loader2, Unlink } from "lucide-react";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
errorCodeOf,
sourceErrorMessage,
} from "@/components/documents/sources/source-errors";
import {
systemName,
useDocumentSourcesStatus,
} from "@/components/documents/sources/use-document-sources";
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
DOCUMENT_PICKER_SYSTEMS,
slugForSystem,
type DocumentPickerSystem,
type DocumentSourceConnectionDto,
} from "@/lib/documents/sources/types";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
type Outcome = { tone: "success" | "error"; message: string } | null;
export function DocumentSourcesCard() {
const { t } = useTranslations();
const { eligible, query } = useDocumentSourcesStatus();
const [system, setSystem] = useState<DocumentPickerSystem>("PAPERLESS");
if (!eligible || !query.data?.available) return null;
const connections = query.data.connections;
const connectedCount = connections.length;
return (
<SettingsCard data-testid="document-sources-card">
<SettingsCardHeader
icon={Archive}
title={t("settings.documentSources.title")}
description={t("settings.documentSources.description")}
status={
connectedCount > 0 ? (
<Badge variant="outline">
{t("settings.documentSources.statusConnected")}
</Badge>
) : null
}
/>
<p className="text-sm">{t("settings.documentSources.explainer")}</p>
<div
role="radiogroup"
aria-label={t("settings.documentSources.systemLabel")}
className="bg-muted inline-flex self-start rounded-md p-1"
>
{DOCUMENT_PICKER_SYSTEMS.map((option) => {
const isConnected = connections.some((c) => c.system === option);
return (
<button
key={option}
type="button"
role="radio"
aria-checked={option === system}
onClick={() => setSystem(option)}
data-slot="document-sources-system"
className={cn(
"flex min-h-9 items-center gap-1.5 rounded-sm px-3 text-sm font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
option === system
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{systemName(option)}
{isConnected ? (
<CheckCircle2
className="text-success size-3.5"
aria-label={t("settings.documentSources.statusConnected")}
/>
) : null}
</button>
);
})}
</div>
<SourceForm
// A fresh form per system: nothing typed for one leaks into the other.
key={system}
system={system}
connection={connections.find((c) => c.system === system) ?? null}
allowedOrigins={query.data.allowedOrigins}
/>
</SettingsCard>
);
}
function SourceForm({
system,
connection,
allowedOrigins,
}: {
system: DocumentPickerSystem;
connection: DocumentSourceConnectionDto | null;
allowedOrigins: string[];
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const slug = slugForSystem(system);
const isPapra = system === "PAPRA";
const [baseUrl, setBaseUrl] = useState(connection?.baseUrl ?? "");
const [organizationId, setOrganizationId] = useState(
connection?.organizationId ?? "",
);
const [token, setToken] = useState("");
const [outcome, setOutcome] = useState<Outcome>(null);
const dirty =
baseUrl.trim() !== (connection?.baseUrl ?? "") ||
(isPapra && organizationId.trim() !== (connection?.organizationId ?? "")) ||
token.trim() !== "";
const refresh = () =>
invalidateKeys(queryClient, [queryKeys.documentSources()]);
const save = useMutation({
mutationFn: () =>
apiPut<DocumentSourceConnectionDto>(`/api/documents/sources/${slug}`, {
baseUrl: baseUrl.trim(),
...(isPapra ? { organizationId: organizationId.trim() } : {}),
...(token.trim() ? { token: token.trim() } : {}),
}),
onMutate: () => setOutcome(null),
onSuccess: () => {
setToken("");
setOutcome({
tone: "success",
message: t("settings.documentSources.saved"),
});
void refresh();
},
onError: (err) =>
setOutcome({
tone: "error",
message: sourceErrorMessage(t, errorCodeOf(err)),
}),
});
const test = useMutation({
mutationFn: () =>
apiPost<{ ok: true; latencyMs: number }>(
`/api/documents/sources/${slug}/test`,
),
onMutate: () => setOutcome(null),
onSuccess: (data) => {
setOutcome({
tone: "success",
message: t("settings.documentSources.testOk", {
latency: data.latencyMs,
}),
});
void refresh();
},
onError: (err) =>
setOutcome({
tone: "error",
message: sourceErrorMessage(t, errorCodeOf(err)),
}),
});
const disconnect = useMutation({
mutationFn: () => apiDelete(`/api/documents/sources/${slug}`),
onSuccess: () => {
setBaseUrl("");
setOrganizationId("");
setToken("");
setOutcome(null);
void refresh();
},
onError: (err) =>
setOutcome({
tone: "error",
message: sourceErrorMessage(t, errorCodeOf(err)),
}),
});
const busy = save.isPending || test.isPending || disconnect.isPending;
const canSave =
baseUrl.trim() !== "" &&
(!isPapra || organizationId.trim() !== "") &&
(connection !== null || token.trim() !== "") &&
dirty;
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (canSave && !busy) save.mutate();
}}
data-slot="document-sources-form"
>
<p
className="text-muted-foreground text-xs"
data-slot="document-sources-state"
>
{connection
? connection.lastVerifiedAt
? t("settings.documentSources.connectedSince", {
date: format.date(connection.lastVerifiedAt),
})
: t("settings.documentSources.statusConnected")
: t("settings.documentSources.statusNotConnected")}
</p>
{connection && !connection.originAllowed ? (
<p
role="alert"
className="border-warning/30 bg-warning/10 text-foreground rounded-md border px-3 py-2 text-sm"
>
{t("settings.documentSources.originRemoved")}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor={`document-source-url-${slug}`}>
{t("settings.documentSources.baseUrl")}
</Label>
<Input
id={`document-source-url-${slug}`}
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={allowedOrigins[0] ?? "https://"}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
/>
<p className="text-muted-foreground text-xs">
{t("settings.documentSources.allowedOrigins", {
origins: allowedOrigins.join(", "),
})}
</p>
</div>
{isPapra ? (
<div className="space-y-1.5">
<Label htmlFor="document-source-org">
{t("settings.documentSources.organizationId")}
</Label>
<Input
id="document-source-org"
value={organizationId}
onChange={(e) => setOrganizationId(e.target.value)}
maxLength={128}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
/>
<p className="text-muted-foreground text-xs">
{t("settings.documentSources.organizationHelp")}
</p>
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor={`document-source-token-${slug}`}>
{t("settings.documentSources.token")}
</Label>
<PasswordInput
id={`document-source-token-${slug}`}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
connection ? t("settings.documentSources.tokenSaved") : undefined
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
/>
<p className="text-muted-foreground text-xs">
{isPapra
? t("settings.documentSources.tokenHelpPapra")
: t("settings.documentSources.tokenHelpPaperless")}
</p>
</div>
{outcome ? (
<p
role={outcome.tone === "error" ? "alert" : "status"}
data-slot="document-sources-outcome"
className={cn(
"text-sm",
outcome.tone === "error" ? "text-destructive" : "text-success",
)}
>
{outcome.message}
</p>
) : null}
<SettingsCardActions>
{connection ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
disabled={busy}
>
<Unlink className="size-4" aria-hidden />
{t("settings.documentSources.disconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.documentSources.disconnectTitle", {
name: systemName(system),
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.documentSources.disconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => disconnect.mutate()}>
{t("settings.documentSources.disconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
{connection ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
disabled={busy || dirty || !connection.originAllowed}
onClick={() => test.mutate()}
>
{test.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("settings.documentSources.test")}
</Button>
) : null}
<Button
type="submit"
size="sm"
className="min-h-11"
disabled={busy || !canSave}
>
{save.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{save.isPending
? t("settings.documentSources.saving")
: t("settings.documentSources.save")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -28,6 +28,8 @@ import { FolderOpen } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
export function VaccinationDocumentPicker({
enabled,
@@ -73,6 +75,14 @@ export function VaccinationDocumentPicker({
options={vault.options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
extraAction={
// Joins the form's selection; the dose's save writes the links.
<ImportFromSourceButton
className="min-h-9"
kind="VACCINATION"
onImported={(ids) => onChange(mergeIds(documentIds, ids))}
/>
}
/>
</div>
);