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({
})}