From 6534c3f9e8887c0db71e5e717289ab9b2e3af3de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 26 Sep 2026 14:32:46 +0200
Subject: [PATCH] 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.
---
messages/de.json | 15 +-
messages/en.json | 15 +-
messages/es.json | 15 +-
messages/fr.json | 15 +-
messages/it.json | 15 +-
messages/ko.json | 15 +-
messages/pl.json | 15 +-
.../__tests__/document-sources-ui.test.tsx | 47 ++-
.../sources/document-source-picker.tsx | 216 ++++++++++----
.../documents/sources/segmented-choice.tsx | 110 +++++++
.../documents/sources/use-document-sources.ts | 37 ++-
.../integrations/document-sources-card.tsx | 280 ++++++++++--------
12 files changed, 567 insertions(+), 228 deletions(-)
create mode 100644 src/components/documents/sources/segmented-choice.tsx
diff --git a/messages/de.json b/messages/de.json
index b0b80d39f..b0426eec2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 324370d50..f42ec1e19 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index f367a4433..6f9e5127b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index 1037939c5..93f029e4c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index 1d4c709f7..23f268c99 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/ko.json b/messages/ko.json
index 42c93bcf7..fef692073 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 680921471..7cb49c5ae 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
index 2e3f93881..208163bcb 100644
--- a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
+++ b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
@@ -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("", () => {
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(, {
+ 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(, 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(, 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(, 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"]);
diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx
index ba9dca00d..09dc62404 100644
--- a/src/components/documents/sources/document-source-picker.tsx
+++ b/src/components/documents/sources/document-source-picker.tsx
@@ -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(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 | 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({
})}