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

{finished ? ( - +
+ + +
) : ( - ))} - + {systems.length > 1 && active ? ( + ) : null}
@@ -423,7 +449,7 @@ export function DocumentSourcePicker({ setTag(e.target.value)} + onChange={(e) => filterSetter(setTag)(e.target.value)} disabled={tags.isPending && !tags.isError} > @@ -441,7 +467,7 @@ export function DocumentSourcePicker({
@@ -452,7 +478,7 @@ export function DocumentSourcePicker({ @@ -461,6 +487,7 @@ export function DocumentSourcePicker({ + ); + })} + + ); +} diff --git a/src/components/documents/sources/use-document-sources.ts b/src/components/documents/sources/use-document-sources.ts index 13b1a4692..e19329ca2 100644 --- a/src/components/documents/sources/use-document-sources.ts +++ b/src/components/documents/sources/use-document-sources.ts @@ -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("/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. */ diff --git a/src/components/settings/integrations/document-sources-card.tsx b/src/components/settings/integrations/document-sources-card.tsx index d63cfd30d..8a0cf216e 100644 --- a/src/components/settings/integrations/document-sources-card.tsx +++ b/src/components/settings/integrations/document-sources-card.tsx @@ -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("PAPERLESS"); + const { ownRecord, eligible, query } = useDocumentSourcesStatus(); + const [picked, setPicked] = useState("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 ( @@ -77,7 +87,7 @@ export function DocumentSourcesCard() { title={t("settings.documentSources.title")} description={t("settings.documentSources.description")} status={ - connectedCount > 0 ? ( + connections.length > 0 ? ( {t("settings.documentSources.statusConnected")} @@ -85,62 +95,67 @@ export function DocumentSourcesCard() { } /> -

{t("settings.documentSources.explainer")}

+

+ {editable + ? t("settings.documentSources.explainer") + : eligible + ? t("settings.documentSources.unavailableExplainer") + : t("settings.documentSources.moduleOffExplainer")} +

-
- {DOCUMENT_PICKER_SYSTEMS.map((option) => { - const isConnected = connections.some((c) => c.system === option); - return ( - - ); - })} -
+ + )} + /> + ) : null} c.system === system) ?? null} - allowedOrigins={query.data.allowedOrigins} + allowedOrigins={data.allowedOrigins} + editable={editable} />
); } +/** 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")}

- {connection && !connection.originAllowed ? ( + {editable && connection && !connection.originAllowed ? (

) : null} -

- - setBaseUrl(e.target.value)} - placeholder={allowedOrigins[0] ?? "https://"} - maxLength={2048} - autoComplete="off" - inputMode="url" - spellCheck={false} - autoCapitalize="none" - /> -

- {t("settings.documentSources.allowedOrigins", { - origins: allowedOrigins.join(", "), - })} -

-
+ {editable ? ( + <> +
+ + setBaseUrl(e.target.value)} + placeholder={allowedOrigins[0] ?? "https://"} + maxLength={2048} + autoComplete="off" + inputMode="url" + spellCheck={false} + autoCapitalize="none" + /> +

+ {t("settings.documentSources.allowedOrigins", { + origins: allowedOrigins.join(", "), + })} +

+
- {isPapra ? ( -
- - setOrganizationId(e.target.value)} - maxLength={128} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - /> -

- {t("settings.documentSources.organizationHelp")} -

-
+ {isPapra ? ( +
+ + setOrganizationId(e.target.value)} + maxLength={128} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + /> +

+ {t("settings.documentSources.organizationHelp")} +

+
+ ) : null} + +
+ + setToken(e.target.value)} + placeholder={ + keepsToken + ? t("settings.documentSources.tokenSaved") + : undefined + } + maxLength={512} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + /> +

+ {isPapra + ? t("settings.documentSources.tokenHelpPapra") + : t("settings.documentSources.tokenHelpPaperless")} +

+ {connection && !keepsToken && baseUrl.trim() !== "" ? ( +

+ {t("settings.documentSources.tokenNewAddress")} +

+ ) : null} +
+ + ) : connection ? ( +

+ {connection.baseUrl} +

) : null} -
- - setToken(e.target.value)} - placeholder={ - connection ? t("settings.documentSources.tokenSaved") : undefined - } - maxLength={512} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - /> -

- {isPapra - ? t("settings.documentSources.tokenHelpPapra") - : t("settings.documentSources.tokenHelpPaperless")} -

-
- {outcome ? ( ) : null} - {connection ? ( + {connection && editable ? ( + {editable ? ( + + ) : null} );