fix(documents): picker and settings card follow-ups

The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 14:32:46 +02:00
1 parent 6a751bc51d
commit 6534c3f9e8
12 files changed
+567 -228

No files matched your search

+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Organisations-ID",
"organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra.",
"token": "API-Token",
"tokenSaved": "Gespeichert. Leer lassen, um es zu behalten.",
"tokenSaved": "Für diese Adresse gespeichert. Leer lassen, um es zu behalten.",
"tokenHelpPaperless": "In Paperless-ngx: Mein Profil, API-Token.",
"tokenHelpPapra": "In Papra: ein API-Schlüssel mit documents:read und tags:read.",
"save": "Speichern und testen",
@@ -6723,7 +6723,10 @@
"testOk": "Das Archiv hat in {latency} ms geantwortet.",
"disconnect": "Trennen",
"disconnectTitle": "{name} trennen?",
"disconnectDescription": "HealthLog vergisst Adresse und Token. Bereits importierte Dokumente bleiben in HealthLog."
"disconnectDescription": "HealthLog vergisst Adresse und Token. Bereits importierte Dokumente bleiben in HealthLog.",
"unavailableExplainer": "Dieser Server erlaubt keine Dokumentarchive mehr, die Verbindung unten ist daher nicht nutzbar. Du kannst sie weiterhin entfernen.",
"moduleOffExplainer": "Dokumente sind ausgeschaltet, die Verbindung unten wird daher nicht genutzt. Du kannst sie weiterhin entfernen.",
"tokenNewAddress": "Für eine neue Adresse wird das API-Token erneut gebraucht."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Dokumentarchive lassen sich nur im Browser verwenden.",
"invalidAddress": "Gib die vollständige Adresse ein, beginnend mit https:// oder http://.",
"organizationRequired": "Gib die Organisations-ID aus Papra ein.",
"tokenRequired": "Gib das API-Token ein.",
"tokenRequired": "Gib das API-Token ein. Eine neue Adresse braucht es erneut.",
"fileTooLarge": "Die Datei ist größer, als HealthLog annimmt.",
"quotaExceeded": "Dein Dokumentenspeicher ist voll.",
"fileType": "HealthLog nimmt diesen Dateityp nicht an.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "Andere Uploads laufen noch. Versuche es gleich noch einmal.",
"moduleDisabled": "Dokumente sind in den Einstellungen ausgeschaltet.",
"generic": "Etwas ist schiefgelaufen. Versuche es erneut."
}
},
"pickMore": "Weitere auswählen",
"finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.",
"maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.",
"noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Organization id",
"organizationHelp": "The part after /organizations/ in the Papra address bar.",
"token": "API token",
"tokenSaved": "Saved. Leave empty to keep it.",
"tokenSaved": "Saved for this address. Leave empty to keep it.",
"tokenHelpPaperless": "In Paperless-ngx: My Profile, API token.",
"tokenHelpPapra": "In Papra: an API key with documents:read and tags:read.",
"save": "Save and test",
@@ -6723,7 +6723,10 @@
"testOk": "The archive answered in {latency} ms.",
"disconnect": "Disconnect",
"disconnectTitle": "Disconnect {name}?",
"disconnectDescription": "HealthLog forgets the address and the token. Documents you already imported stay in HealthLog."
"disconnectDescription": "HealthLog forgets the address and the token. Documents you already imported stay in HealthLog.",
"unavailableExplainer": "This server no longer allows document archives, so the connection below cannot be used. You can still remove it.",
"moduleOffExplainer": "Documents are switched off, so the connection below is not used. You can still remove it.",
"tokenNewAddress": "A new address needs the API token again."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Document archives can only be used in the browser.",
"invalidAddress": "Enter the full address, starting with https:// or http://.",
"organizationRequired": "Enter the Papra organization id.",
"tokenRequired": "Enter the API token.",
"tokenRequired": "Enter the API token. A new address needs it again.",
"fileTooLarge": "The file is larger than HealthLog accepts.",
"quotaExceeded": "Your document storage is full.",
"fileType": "HealthLog does not accept this file type.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "Other uploads are still running. Try again in a moment.",
"moduleDisabled": "Documents are switched off in Settings.",
"generic": "Something went wrong. Try again."
}
},
"pickMore": "Pick more",
"finishedHint": "Search again or change a filter to pick more documents.",
"maxReached": "You can import up to {max} documents at a time.",
"noMatchHint": "Try part of the name, or clear the tag and dates."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Id de organización",
"organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra.",
"token": "Token de la API",
"tokenSaved": "Guardado. Déjalo vacío para conservarlo.",
"tokenSaved": "Guardado para esta dirección. Déjalo vacío para conservarlo.",
"tokenHelpPaperless": "En Paperless-ngx: Mi perfil, token de la API.",
"tokenHelpPapra": "En Papra: una clave de API con documents:read y tags:read.",
"save": "Guardar y probar",
@@ -6723,7 +6723,10 @@
"testOk": "El archivo respondió en {latency} ms.",
"disconnect": "Desconectar",
"disconnectTitle": "¿Desconectar {name}?",
"disconnectDescription": "HealthLog olvida la dirección y el token. Los documentos ya importados se quedan en HealthLog."
"disconnectDescription": "HealthLog olvida la dirección y el token. Los documentos ya importados se quedan en HealthLog.",
"unavailableExplainer": "Este servidor ya no permite archivos de documentos, así que la conexión de abajo no se puede usar. Aún puedes eliminarla.",
"moduleOffExplainer": "Los documentos están desactivados, así que la conexión de abajo no se usa. Aún puedes eliminarla.",
"tokenNewAddress": "Una dirección nueva necesita de nuevo el token de la API."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Los archivos de documentos solo se pueden usar en el navegador.",
"invalidAddress": "Introduce la dirección completa, empezando por https:// o http://.",
"organizationRequired": "Introduce el id de organización de Papra.",
"tokenRequired": "Introduce el token de la API.",
"tokenRequired": "Introduce el token de la API. Una dirección nueva lo necesita de nuevo.",
"fileTooLarge": "El archivo supera el tamaño que acepta HealthLog.",
"quotaExceeded": "Tu almacenamiento de documentos está lleno.",
"fileType": "HealthLog no acepta este tipo de archivo.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "Aún hay otras subidas en curso. Inténtalo en un momento.",
"moduleDisabled": "Los documentos están desactivados en Ajustes.",
"generic": "Algo salió mal. Inténtalo de nuevo."
}
},
"pickMore": "Elegir más",
"finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.",
"maxReached": "Puedes importar como máximo {max} documentos a la vez.",
"noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Identifiant d'organisation",
"organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra.",
"token": "Jeton d'API",
"tokenSaved": "Enregistré. Laissez vide pour le conserver.",
"tokenSaved": "Enregistré pour cette adresse. Laissez vide pour le conserver.",
"tokenHelpPaperless": "Dans Paperless-ngx : Mon profil, jeton d'API.",
"tokenHelpPapra": "Dans Papra : une clé d'API avec documents:read et tags:read.",
"save": "Enregistrer et tester",
@@ -6723,7 +6723,10 @@
"testOk": "L'archive a répondu en {latency} ms.",
"disconnect": "Déconnecter",
"disconnectTitle": "Déconnecter {name} ?",
"disconnectDescription": "HealthLog oublie l'adresse et le jeton. Les documents déjà importés restent dans HealthLog."
"disconnectDescription": "HealthLog oublie l'adresse et le jeton. Les documents déjà importés restent dans HealthLog.",
"unavailableExplainer": "Ce serveur n'autorise plus les archives de documents ; la connexion ci-dessous ne peut donc pas servir. Vous pouvez toujours la supprimer.",
"moduleOffExplainer": "Les documents sont désactivés ; la connexion ci-dessous n'est donc pas utilisée. Vous pouvez toujours la supprimer.",
"tokenNewAddress": "Une nouvelle adresse demande de saisir à nouveau le jeton d'API."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Les archives de documents ne s'utilisent que dans le navigateur.",
"invalidAddress": "Saisissez l'adresse complète, commençant par https:// ou http://.",
"organizationRequired": "Saisissez l'identifiant d'organisation Papra.",
"tokenRequired": "Saisissez le jeton d'API.",
"tokenRequired": "Saisissez le jeton d'API. Une nouvelle adresse le demande à nouveau.",
"fileTooLarge": "Le fichier dépasse la taille acceptée par HealthLog.",
"quotaExceeded": "Votre espace de documents est plein.",
"fileType": "HealthLog n'accepte pas ce type de fichier.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "D'autres envois sont en cours. Réessayez dans un instant.",
"moduleDisabled": "Les documents sont désactivés dans les réglages.",
"generic": "Un problème est survenu. Réessayez."
}
},
"pickMore": "En choisir d'autres",
"finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.",
"maxReached": "Vous pouvez importer au plus {max} documents à la fois.",
"noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Id organizzazione",
"organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra.",
"token": "Token API",
"tokenSaved": "Salvato. Lascia vuoto per mantenerlo.",
"tokenSaved": "Salvato per questo indirizzo. Lascia vuoto per mantenerlo.",
"tokenHelpPaperless": "In Paperless-ngx: Il mio profilo, token API.",
"tokenHelpPapra": "In Papra: una chiave API con documents:read e tags:read.",
"save": "Salva e prova",
@@ -6723,7 +6723,10 @@
"testOk": "L'archivio ha risposto in {latency} ms.",
"disconnect": "Scollega",
"disconnectTitle": "Scollegare {name}?",
"disconnectDescription": "HealthLog dimentica indirizzo e token. I documenti già importati restano in HealthLog."
"disconnectDescription": "HealthLog dimentica indirizzo e token. I documenti già importati restano in HealthLog.",
"unavailableExplainer": "Questo server non consente più gli archivi di documenti, quindi il collegamento qui sotto non si può usare. Puoi comunque rimuoverlo.",
"moduleOffExplainer": "I documenti sono disattivati, quindi il collegamento qui sotto non viene usato. Puoi comunque rimuoverlo.",
"tokenNewAddress": "Un nuovo indirizzo richiede di nuovo il token API."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Gli archivi di documenti si usano solo nel browser.",
"invalidAddress": "Inserisci l'indirizzo completo, che inizia con https:// o http://.",
"organizationRequired": "Inserisci l'id dell'organizzazione Papra.",
"tokenRequired": "Inserisci il token API.",
"tokenRequired": "Inserisci il token API. Un nuovo indirizzo lo richiede di nuovo.",
"fileTooLarge": "Il file è più grande di quanto HealthLog accetti.",
"quotaExceeded": "Lo spazio per i documenti è pieno.",
"fileType": "HealthLog non accetta questo tipo di file.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "Altri caricamenti sono ancora in corso. Riprova tra un momento.",
"moduleDisabled": "I documenti sono disattivati nelle impostazioni.",
"generic": "Qualcosa è andato storto. Riprova."
}
},
"pickMore": "Scegline altri",
"finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.",
"maxReached": "Puoi importare al massimo {max} documenti alla volta.",
"noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "조직 ID",
"organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다.",
"token": "API 토큰",
"tokenSaved": "저장됨. 유지하려면 비워 두세요.",
"tokenSaved": "이 주소에 저장됨. 유지하려면 비워 두세요.",
"tokenHelpPaperless": "Paperless-ngx: 내 프로필의 API 토큰.",
"tokenHelpPapra": "Papra: documents:read와 tags:read 권한이 있는 API 키.",
"save": "저장하고 테스트",
@@ -6723,7 +6723,10 @@
"testOk": "보관함이 {latency}ms 만에 응답했습니다.",
"disconnect": "연결 해제",
"disconnectTitle": "{name} 연결을 해제할까요?",
"disconnectDescription": "HealthLog가 주소와 토큰을 잊습니다. 이미 가져온 문서는 HealthLog에 남습니다."
"disconnectDescription": "HealthLog가 주소와 토큰을 잊습니다. 이미 가져온 문서는 HealthLog에 남습니다.",
"unavailableExplainer": "이 서버는 더 이상 문서 보관함을 허용하지 않아 아래 연결을 사용할 수 없습니다. 연결은 여전히 삭제할 수 있습니다.",
"moduleOffExplainer": "문서 기능이 꺼져 있어 아래 연결은 사용되지 않습니다. 연결은 여전히 삭제할 수 있습니다.",
"tokenNewAddress": "새 주소에는 API 토큰을 다시 입력해야 합니다."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "문서 보관함은 브라우저에서만 사용할 수 있습니다.",
"invalidAddress": "https:// 또는 http://로 시작하는 전체 주소를 입력하세요.",
"organizationRequired": "Papra 조직 ID를 입력하세요.",
"tokenRequired": "API 토큰을 입력하세요.",
"tokenRequired": "API 토큰을 입력하세요. 새 주소에는 다시 필요합니다.",
"fileTooLarge": "파일이 HealthLog가 허용하는 크기보다 큽니다.",
"quotaExceeded": "문서 저장 공간이 가득 찼습니다.",
"fileType": "HealthLog는 이 파일 형식을 받지 않습니다.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "다른 업로드가 아직 진행 중입니다. 잠시 후 다시 시도하세요.",
"moduleDisabled": "설정에서 문서 기능이 꺼져 있습니다.",
"generic": "문제가 발생했습니다. 다시 시도하세요."
}
},
"pickMore": "더 고르기",
"finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.",
"maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.",
"noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요."
}
},
"selfDescription": {
+11 -4
View File
@@ -6713,7 +6713,7 @@
"organizationId": "Identyfikator organizacji",
"organizationHelp": "Część po /organizations/ w pasku adresu Papra.",
"token": "Token API",
"tokenSaved": "Zapisano. Zostaw puste, aby go zachować.",
"tokenSaved": "Zapisano dla tego adresu. Zostaw puste, aby go zachować.",
"tokenHelpPaperless": "W Paperless-ngx: Mój profil, token API.",
"tokenHelpPapra": "W Papra: klucz API z uprawnieniami documents:read i tags:read.",
"save": "Zapisz i sprawdź",
@@ -6723,7 +6723,10 @@
"testOk": "Archiwum odpowiedziało w {latency} ms.",
"disconnect": "Rozłącz",
"disconnectTitle": "Rozłączyć {name}?",
"disconnectDescription": "HealthLog zapomina adres i token. Zaimportowane już dokumenty zostają w HealthLog."
"disconnectDescription": "HealthLog zapomina adres i token. Zaimportowane już dokumenty zostają w HealthLog.",
"unavailableExplainer": "Ten serwer nie pozwala już na archiwa dokumentów, więc poniższego połączenia nie da się użyć. Nadal możesz je usunąć.",
"moduleOffExplainer": "Dokumenty są wyłączone, więc poniższe połączenie nie jest używane. Nadal możesz je usunąć.",
"tokenNewAddress": "Nowy adres wymaga ponownego podania tokenu API."
}
},
"admin": {
@@ -10223,7 +10226,7 @@
"browserOnly": "Z archiwów dokumentów można korzystać tylko w przeglądarce.",
"invalidAddress": "Wpisz pełny adres, zaczynający się od https:// lub http://.",
"organizationRequired": "Wpisz identyfikator organizacji Papra.",
"tokenRequired": "Wpisz token API.",
"tokenRequired": "Wpisz token API. Nowy adres wymaga go ponownie.",
"fileTooLarge": "Plik jest większy, niż przyjmuje HealthLog.",
"quotaExceeded": "Miejsce na dokumenty jest pełne.",
"fileType": "HealthLog nie przyjmuje tego typu pliku.",
@@ -10231,7 +10234,11 @@
"uploadBusy": "Trwają jeszcze inne przesyłania. Spróbuj za chwilę.",
"moduleDisabled": "Dokumenty są wyłączone w ustawieniach.",
"generic": "Coś poszło nie tak. Spróbuj ponownie."
}
},
"pickMore": "Wybierz więcej",
"finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.",
"maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.",
"noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty."
}
},
"selfDescription": {
@@ -50,6 +50,7 @@ import { queryKeys } from "@/lib/query-keys";
import { ImportFromSourceButton } from "../import-from-source-button";
import { mergeIds } from "../merge-ids";
import { nextChoiceIndex } from "../segmented-choice";
import { errorCodeOf, sourceErrorMessage } from "../source-errors";
const PAPERLESS = {
@@ -162,13 +163,44 @@ describe("<DocumentSourcesCard>", () => {
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("Saved for this address. 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("keeps a stored connection visible and removable once the operator list is gone", () => {
const html = render(<DocumentSourcesCard />, {
available: false,
allowedOrigins: [],
connections: [{ ...PAPERLESS, originAllowed: false }],
});
expect(html).toContain('data-testid="document-sources-card"');
expect(html).toContain("no longer allows document archives");
expect(html).toContain("Disconnect");
expect(html).not.toContain("Save and test");
expect(html).not.toContain('id="document-source-token-paperless"');
});
it("keeps a stored connection removable with the documents module off", () => {
modules = { inboundDocuments: false };
try {
const html = render(<DocumentSourcesCard />, on([PAPERLESS]));
expect(html).toContain("Documents are switched off");
expect(html).toContain("Disconnect");
expect(html).not.toContain("Save and test");
} finally {
modules = { inboundDocuments: true };
}
});
it("uses one tab stop for the system switch", () => {
const html = render(<DocumentSourcesCard />, on([PAPERLESS]));
expect(html.match(/role="radio"[^>]*tabindex="0"/gi)?.length).toBe(1);
expect(html.match(/role="radio"[^>]*tabindex="-1"/gi)?.length).toBe(1);
});
it("offers no disconnect or test for a system that is not connected", () => {
const html = render(<DocumentSourcesCard />, on([]));
expect(html).toContain("Not connected");
@@ -223,6 +255,19 @@ describe("source error sentences", () => {
});
});
describe("nextChoiceIndex", () => {
it("moves with the arrows, wraps, and jumps with Home and End", () => {
expect(nextChoiceIndex("ArrowRight", 0, 2)).toBe(1);
expect(nextChoiceIndex("ArrowRight", 1, 2)).toBe(0);
expect(nextChoiceIndex("ArrowLeft", 0, 2)).toBe(1);
expect(nextChoiceIndex("ArrowDown", 0, 3)).toBe(1);
expect(nextChoiceIndex("ArrowUp", 0, 3)).toBe(2);
expect(nextChoiceIndex("Home", 2, 3)).toBe(0);
expect(nextChoiceIndex("End", 0, 3)).toBe(2);
expect(nextChoiceIndex("Enter", 0, 3)).toBeNull();
});
});
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"]);
@@ -38,12 +38,14 @@ import {
CircleCheck,
Loader2,
Search,
SearchX,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -78,6 +80,7 @@ import {
isStoppingError,
sourceErrorMessage,
} from "./source-errors";
import { SegmentedChoice } from "./segmented-choice";
import { systemName } from "./use-document-sources";
export interface DocumentPickerLink {
@@ -134,6 +137,20 @@ export function DocumentSourcePicker({
const [running, setRunning] = useState(false);
const [finished, setFinished] = useState(false);
const [stopMessage, setStopMessage] = useState<string | null>(null);
const [atMax, setAtMax] = useState(false);
/** The latest per-row result, for screen readers (aria-live below). */
const [announcement, setAnnouncement] = useState("");
// A new search or filter starts over: a selection made in one result list
// must not ride along unseen into another, and a finished run's result
// lines belong to the list they were made in.
const startOver = () => {
setSelected(new Map());
setRuns(new Map());
setFinished(false);
setStopMessage(null);
setAtMax(false);
};
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
@@ -145,11 +162,20 @@ export function DocumentSourcePicker({
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(
() => setQ(value.trim()),
SEARCH_DEBOUNCE_MS,
);
debounceRef.current = setTimeout(() => {
const next = value.trim();
if (next !== q) {
startOver();
setQ(next);
}
}, SEARCH_DEBOUNCE_MS);
};
const filterSetter =
(set: (value: string) => void) =>
(value: string): void => {
startOver();
set(value);
};
const reset = () => {
setSearchDraft("");
@@ -157,10 +183,7 @@ export function DocumentSourcePicker({
setTag("");
setFrom("");
setTo("");
setSelected(new Map());
setRuns(new Map());
setFinished(false);
setStopMessage(null);
startOver();
};
const switchSystem = (next: DocumentPickerSystem) => {
@@ -225,14 +248,18 @@ export function DocumentSourcePicker({
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;
});
const next = new Map(selected);
if (next.has(row.sourceId)) {
next.delete(row.sourceId);
setAtMax(false);
} else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) {
next.set(row.sourceId, row.title);
} else {
// Said rather than silently ignored: the tick that did not appear.
setAtMax(true);
return;
}
setSelected(next);
};
async function runImport() {
@@ -242,8 +269,12 @@ export function DocumentSourcePicker({
const order = [...selected.keys()];
setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
const handed: string[] = [];
const mark = (id: string, run: RowRun) =>
const mark = (id: string, run: RowRun) => {
setRuns((prev) => new Map(prev).set(id, run));
const title = selected.get(id) ?? id;
const said = runLabel(t, run);
if (said) setAnnouncement(`${title}: ${said}`);
};
for (const sourceId of order) {
mark(sourceId, { state: "working" });
@@ -318,14 +349,26 @@ export function DocumentSourcePicker({
})}
</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>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={startOver}
data-slot="document-source-pick-more"
>
{t("documents.sourcePicker.pickMore")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onOpenChange(false)}
>
{t("documents.sourcePicker.done")}
</Button>
</div>
) : (
<Button
type="button"
@@ -363,32 +406,15 @@ export function DocumentSourcePicker({
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>
{systems.length > 1 && active ? (
<SegmentedChoice
options={systems}
value={active}
onChange={switchSystem}
label={t("documents.sourcePicker.sourceLabel")}
disabled={running}
renderOption={systemName}
/>
) : null}
<div className="space-y-1.5">
@@ -423,7 +449,7 @@ export function DocumentSourcePicker({
<NativeSelect
id="document-source-tag"
value={tag}
onChange={(e) => setTag(e.target.value)}
onChange={(e) => filterSetter(setTag)(e.target.value)}
disabled={tags.isPending && !tags.isError}
>
<option value="">{t("documents.sourcePicker.tagAny")}</option>
@@ -441,7 +467,7 @@ export function DocumentSourcePicker({
<DateField
id="document-source-from"
value={from}
onChange={setFrom}
onChange={filterSetter(setFrom)}
max={to || undefined}
/>
</div>
@@ -452,7 +478,7 @@ export function DocumentSourcePicker({
<DateField
id="document-source-to"
value={to}
onChange={setTo}
onChange={filterSetter(setTo)}
min={from || undefined}
/>
</div>
@@ -461,6 +487,7 @@ export function DocumentSourcePicker({
<button
type="button"
onClick={() => {
startOver();
setTag("");
setFrom("");
setTo("");
@@ -473,13 +500,35 @@ export function DocumentSourcePicker({
) : null}
{finished ? (
<WrittenOutcomeLine
outcome={runOutcome(counts)}
message={t("documents.sourcePicker.summary", counts)}
testId="document-source-summary"
/>
<div className="space-y-1">
<WrittenOutcomeLine
outcome={runOutcome(counts)}
message={t("documents.sourcePicker.summary", counts)}
testId="document-source-summary"
/>
<p className="text-muted-foreground text-xs">
{t("documents.sourcePicker.finishedHint")}
</p>
</div>
) : null}
{atMax ? (
<p
role="status"
className="text-muted-foreground text-xs"
data-slot="document-source-at-max"
>
{t("documents.sourcePicker.maxReached", {
max: DOCUMENT_PICKER_MAX_SELECTION,
})}
</p>
) : null}
{/* Each row's result as it lands, for a screen reader. */}
<div aria-live="polite" className="sr-only">
{announcement}
</div>
{stopMessage ? (
<p
role="alert"
@@ -507,11 +556,21 @@ export function DocumentSourcePicker({
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>
<EmptyState
variant="plain"
size="compact"
icon={<SearchX className="size-6" aria-hidden />}
title={
q || hasFilters
? t("documents.sourcePicker.noMatch")
: t("documents.sourcePicker.empty", { name })
}
description={
q || hasFilters
? t("documents.sourcePicker.noMatchHint")
: undefined
}
/>
) : (
<ul
className="max-h-[45vh] space-y-1.5 overflow-y-auto overscroll-contain"
@@ -545,7 +604,10 @@ export function DocumentSourcePicker({
on && "border-primary/40 bg-primary/5",
// A row that just ran keeps full contrast: its result is
// the thing to read.
!allowed && !run && "opacity-50",
// After a run, the rows it did not touch step back:
// the ones with a result are what to read now.
((!allowed && !run) || (finished && !run)) &&
"opacity-50",
)}
>
<Check
@@ -621,6 +683,30 @@ function runOutcome(counts: {
return held > 0 ? "partial" : "failed";
}
/** A row's result in words, or null while it has none yet. */
function runLabel(
t: (key: string, params?: Record<string, string | number>) => string,
run: RowRun,
): string | null {
switch (run.state) {
case "waiting":
case "working":
return null;
case "failed":
return run.message;
case "deleted":
return t("documents.sourcePicker.result.deleted");
case "imported":
return run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported");
case "duplicate":
return run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate");
}
}
/** One row's progress or result, under its title. */
function RunLine({ run }: { run: RowRun }) {
const { t } = useTranslations();
@@ -0,0 +1,110 @@
"use client";
/**
* A small segmented radio group (Paperless-ngx | Papra) with the keyboard
* contract of a radio group: one tab stop (the checked option), arrow keys
* move and select, Home and End jump to the ends. Shared by the document
* picker's source switch and the settings card's system switch.
*/
import { useRef, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The option an arrow, Home or End key moves to from `index`, wrapping at the
* ends; null for any other key.
*/
export function nextChoiceIndex(
key: string,
index: number,
count: number,
): number | null {
if (count <= 0) return null;
switch (key) {
case "ArrowRight":
case "ArrowDown":
return (index + 1) % count;
case "ArrowLeft":
case "ArrowUp":
return (index - 1 + count) % count;
case "Home":
return 0;
case "End":
return count - 1;
default:
return null;
}
}
export function SegmentedChoice<T extends string>({
options,
value,
onChange,
label,
disabled = false,
renderOption,
slot,
className,
}: {
options: readonly T[];
value: T;
onChange: (value: T) => void;
/** Accessible name of the group. */
label: string;
disabled?: boolean;
renderOption: (option: T) => ReactNode;
slot?: string;
className?: string;
}) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, i: number) => {
const next = nextChoiceIndex(event.key, i, options.length);
if (next === null) return;
event.preventDefault();
if (disabled) return;
onChange(options[next]);
refs.current[next]?.focus();
};
return (
<div
role="radiogroup"
aria-label={label}
aria-disabled={disabled || undefined}
className={cn(
"bg-muted inline-flex self-start rounded-md p-1",
className,
)}
>
{options.map((option, i) => {
const checked = option === value;
return (
<button
key={option}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
disabled={disabled}
onClick={() => onChange(option)}
onKeyDown={(event) => onKeyDown(event, i)}
data-slot={slot}
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",
checked
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{renderOption(option)}
</button>
);
})}
</div>
);
}
@@ -3,12 +3,12 @@
/**
* 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.
* Reads `GET /api/documents/sources` only in the person's own record: 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 (the documents
* module on, the operator's list set, the origin still on it), in a stable
* order; the import buttons read only that.
*/
import { useQuery } from "@tanstack/react-query";
@@ -24,25 +24,32 @@ import { queryKeys } from "@/lib/query-keys";
export function useDocumentSourcesStatus() {
const { user } = useAuth();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const eligible =
user?.modules?.inboundDocuments === true &&
// The status read touches only the person's own rows and never the
// network, so it runs in one's own record whatever the module says: a
// connection stored before Documents was switched off, or before the
// operator removed the list, must stay visible so it can be deleted.
const ownRecord =
user !== null &&
user !== undefined &&
!inSharedRecord &&
canManageDomain("documents");
const eligible = ownRecord && user?.modules?.inboundDocuments === true;
const query = useQuery({
queryKey: queryKeys.documentSourcesStatus(),
enabled: eligible,
enabled: ownRecord,
queryFn: () => apiGet<DocumentSourcesStatusDto>("/api/documents/sources"),
staleTime: 60_000,
});
const connected: DocumentPickerSystem[] = eligible
? (query.data?.connections ?? [])
.filter((c) => c.originAllowed)
.map((c) => c.system)
: [];
const connected: DocumentPickerSystem[] =
eligible && query.data?.available
? query.data.connections
.filter((c) => c.originAllowed)
.map((c) => c.system)
: [];
return { eligible, query, connected };
return { ownRecord, eligible, query, connected };
}
/** Display name of a system; product names, not translated. */
@@ -39,6 +39,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { SegmentedChoice } from "@/components/documents/sources/segmented-choice";
import {
errorCodeOf,
sourceErrorMessage,
@@ -56,19 +57,28 @@ import {
} 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");
const { ownRecord, eligible, query } = useDocumentSourcesStatus();
const [picked, setPicked] = useState<DocumentPickerSystem>("PAPERLESS");
if (!eligible || !query.data?.available) return null;
const data = query.data;
const connections = data?.connections ?? [];
// Editing needs the operator's list and the documents module. Without
// either, a connection that is still stored stays on the card so its token
// can be deleted; with none stored there is nothing to show.
const editable = eligible && data?.available === true;
if (!ownRecord || !data || (!editable && connections.length === 0)) {
return null;
}
const connections = query.data.connections;
const connectedCount = connections.length;
const options: readonly DocumentPickerSystem[] = editable
? DOCUMENT_PICKER_SYSTEMS
: connections.map((c) => c.system);
const system = options.includes(picked) ? picked : options[0];
return (
<SettingsCard data-testid="document-sources-card">
@@ -77,7 +87,7 @@ export function DocumentSourcesCard() {
title={t("settings.documentSources.title")}
description={t("settings.documentSources.description")}
status={
connectedCount > 0 ? (
connections.length > 0 ? (
<Badge variant="outline">
{t("settings.documentSources.statusConnected")}
</Badge>
@@ -85,62 +95,67 @@ export function DocumentSourcesCard() {
}
/>
<p className="text-sm">{t("settings.documentSources.explainer")}</p>
<p className="text-sm">
{editable
? t("settings.documentSources.explainer")
: eligible
? t("settings.documentSources.unavailableExplainer")
: t("settings.documentSources.moduleOffExplainer")}
</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",
)}
>
{options.length > 1 ? (
<SegmentedChoice
options={options}
value={system}
onChange={setPicked}
label={t("settings.documentSources.systemLabel")}
slot="document-sources-system"
renderOption={(option) => (
<>
{systemName(option)}
{isConnected ? (
{connections.some((c) => c.system === option) ? (
<Check
className="size-3.5"
aria-label={t("settings.documentSources.statusConnected")}
/>
) : null}
</button>
);
})}
</div>
</>
)}
/>
) : null}
<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}
allowedOrigins={data.allowedOrigins}
editable={editable}
/>
</SettingsCard>
);
}
/** The origin of an address, or null while it is not one yet. */
function originOf(value: string): string | null {
try {
return new URL(value.trim()).origin;
} catch {
return null;
}
}
function SourceForm({
system,
connection,
allowedOrigins,
editable,
}: {
system: DocumentPickerSystem;
connection: DocumentSourceConnectionDto | null;
allowedOrigins: string[];
/** False when only removing the stored connection is possible. */
editable: boolean;
}) {
const { t } = useTranslations();
const format = useFormatters();
@@ -225,10 +240,17 @@ function SourceForm({
});
const busy = save.isPending || test.isPending || disconnect.isPending;
// The stored token is reused only on the address's own origin; the server
// refuses to send it anywhere else, so the form asks for it again.
const keepsToken =
connection !== null &&
originOf(baseUrl) !== null &&
originOf(baseUrl) === originOf(connection.baseUrl);
const canSave =
editable &&
baseUrl.trim() !== "" &&
(!isPapra || organizationId.trim() !== "") &&
(connection !== null || token.trim() !== "") &&
(keepsToken || token.trim() !== "") &&
dirty;
return (
@@ -253,7 +275,7 @@ function SourceForm({
: t("settings.documentSources.statusNotConnected")}
</p>
{connection && !connection.originAllowed ? (
{editable && connection && !connection.originAllowed ? (
<p
role="alert"
className="border-warning/30 bg-warning/10 text-foreground rounded-md border px-3 py-2 text-sm"
@@ -262,72 +284,90 @@ function SourceForm({
</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>
{editable ? (
<>
<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>
{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={
keepsToken
? 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>
{connection && !keepsToken && baseUrl.trim() !== "" ? (
<p
className="text-muted-foreground text-xs"
data-slot="document-sources-token-again"
>
{t("settings.documentSources.tokenNewAddress")}
</p>
) : null}
</div>
</>
) : connection ? (
<p className="text-sm break-all" data-slot="document-sources-address">
{connection.baseUrl}
</p>
) : 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 ? (
<WrittenOutcomeLine
outcome={outcome.tone === "error" ? "failed" : "success"}
@@ -371,7 +411,7 @@ function SourceForm({
</AlertDialogContent>
</AlertDialog>
) : null}
{connection ? (
{connection && editable ? (
<Button
type="button"
variant="outline"
@@ -389,22 +429,24 @@ function SourceForm({
{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>
{editable ? (
<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>
) : null}
</SettingsCardActions>
</form>
);