diff --git a/web/lib/opal/src/components/inputs/input-number/InputNumber.test.tsx b/web/lib/opal/src/components/inputs/input-number/InputNumber.test.tsx new file mode 100644 index 0000000000..2b9e538e5f --- /dev/null +++ b/web/lib/opal/src/components/inputs/input-number/InputNumber.test.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import InputNumber from "@opal/components/inputs/input-number/components"; + +function Harness({ min }: { min?: number }) { + const [value, setValue] = useState(null); + return ( + <> + + {String(value)} + + ); +} + +it("takes a typed negative value when min is negative", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "-1"); + + expect(screen.getByRole("textbox")).toHaveValue("-1"); + expect(screen.getByTestId("value")).toHaveTextContent("-1"); +}); + +it("ignores a minus sign when min is not negative", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "-5"); + + expect(screen.getByRole("textbox")).toHaveValue("5"); + expect(screen.getByTestId("value")).toHaveTextContent("5"); +}); diff --git a/web/lib/opal/src/components/inputs/input-number/README.md b/web/lib/opal/src/components/inputs/input-number/README.md index 31ca72ffff..5730a3b3e6 100644 --- a/web/lib/opal/src/components/inputs/input-number/README.md +++ b/web/lib/opal/src/components/inputs/input-number/README.md @@ -4,6 +4,7 @@ A number field with chevron stepper buttons and an optional reset-to-default action. Typing clamps to `max` at once and to `min` on blur, so a partial value below `min` can be typed; stepping clamps to both. +With a negative `min`, the value can be typed with a leading minus sign. ## Props diff --git a/web/lib/opal/src/components/inputs/input-number/components.tsx b/web/lib/opal/src/components/inputs/input-number/components.tsx index a84dcf2918..d1f0fefd61 100644 --- a/web/lib/opal/src/components/inputs/input-number/components.tsx +++ b/web/lib/opal/src/components/inputs/input-number/components.tsx @@ -93,8 +93,12 @@ export default function InputNumber({ value === null ? "" : String(value) ); const isDisabled = disabled || variant === "disabled"; - const inputPattern = - decimalPlaces === 0 ? "[0-9]*" : `[0-9]*[.]?[0-9]{0,${decimalPlaces}}`; + // A negative lower bound lets the value start with a minus sign. A lone + // "-" is a value still being typed, so it never reaches onChange. + const allowsNegative: boolean = min !== undefined && min < 0; + const inputPattern: string = + (allowsNegative ? "-?" : "") + + (decimalPlaces === 0 ? "[0-9]*" : `[0-9]*[.]?[0-9]{0,${decimalPlaces}}`); // Sync input value when external value changes (e.g., from stepper buttons or reset) React.useEffect(() => { @@ -177,7 +181,14 @@ export default function InputNumber({ ref={inputRef} id={id} type="text" - inputMode={decimalPlaces === 0 ? "numeric" : "decimal"} + // Numeric soft keyboards have no minus key. + inputMode={ + allowsNegative + ? undefined + : decimalPlaces === 0 + ? "numeric" + : "decimal" + } pattern={inputPattern} disabled={isDisabled} value={inputValue} diff --git a/web/lib/opal/src/core/disabled/Disabled.test.tsx b/web/lib/opal/src/core/disabled/Disabled.test.tsx new file mode 100644 index 0000000000..9d24a56678 --- /dev/null +++ b/web/lib/opal/src/core/disabled/Disabled.test.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Disabled } from "@opal/core/disabled/components"; +import { markdown } from "@opal/utils"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; + +it("disables the form controls inside, so the keyboard cannot reach them", async () => { + const user = userEvent.setup(); + render( + + + + + ); + + expect(screen.getByRole("textbox", { name: "name" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + await user.tab(); + expect(document.body).toHaveFocus(); +}); + +it("keeps the controls usable with allowClick", () => { + render( + + + + ); + + expect(screen.getByRole("textbox", { name: "name" })).toBeEnabled(); +}); + +it("keeps a control's state when it toggles between enabled and disabled", async () => { + const user = userEvent.setup(); + function Harness() { + const [locked, setLocked] = useState(false); + return ( + <> + + + + + + ); + } + render(); + + const input = screen.getByRole("textbox", { name: "name" }); + await user.type(input, "kept"); + await user.click(screen.getByRole("button", { name: "toggle" })); + await user.click(screen.getByRole("button", { name: "toggle" })); + + expect(screen.getByRole("textbox", { name: "name" })).toBe(input); + expect(input).toHaveValue("kept"); +}); + +it("gives screen readers the tooltip's reason while disabled, as plain text", () => { + const { rerender } = render( + + + + + + ); + + const status = screen.getByText("Select an account first"); + expect(status).toHaveAttribute("aria-live", "polite"); + + rerender( + + + + + + ); + expect(screen.queryByText("Select an account first")).not.toBeInTheDocument(); +}); diff --git a/web/lib/opal/src/core/disabled/README.md b/web/lib/opal/src/core/disabled/README.md index d1e780e4c6..47b46f8462 100644 --- a/web/lib/opal/src/core/disabled/README.md +++ b/web/lib/opal/src/core/disabled/README.md @@ -6,12 +6,21 @@ Wrapper component that applies baseline disabled CSS (opacity, cursor, pointer-e children. Renders a `
` with the `data-opal-disabled` attribute so styling cascades into all descendants. Works with any children — DOM elements, React components, or fragments. +When `disabled` is true, it also blocks the keyboard: the children sit in a `
` (with +`display: contents`) that is disabled, so the form controls inside leave the tab order and take no +input. `allowClick` keeps the children interactive, so it leaves the keyboard enabled too. The +fieldset is always rendered, so toggling `disabled` does not remount the children. + +A `tooltip` only shows on hover, and nothing in a disabled region can take focus to open it. So +`Disabled` also keeps the tooltip's plain text in a visually hidden, polite live region while it is +disabled: screen readers read the reason in the region and announce it when it changes. + ## Props | Prop | Type | Default | Description | | ------------- | ---------------------------------------- | --------- | ----------------------------------------------------------------------------------- | | `disabled` | `boolean` | `false` | Applies disabled styling when truthy | -| `allowClick` | `boolean` | `false` | Re-enables pointer events while keeping disabled visuals | +| `allowClick` | `boolean` | `false` | Re-enables pointer events and keyboard input while keeping disabled visuals | | `tooltip` | `string \| RichStr` | — | Tooltip shown on hover when disabled (implies `allowClick`). Supports `markdown()`. | | `tooltipSide` | `"top" \| "bottom" \| "left" \| "right"` | `"right"` | Which side the tooltip appears on | diff --git a/web/lib/opal/src/core/disabled/components.tsx b/web/lib/opal/src/core/disabled/components.tsx index 921d86475f..047692a96c 100644 --- a/web/lib/opal/src/core/disabled/components.tsx +++ b/web/lib/opal/src/core/disabled/components.tsx @@ -1,6 +1,7 @@ import "@opal/core/disabled/styles.css"; import React from "react"; import { Tooltip, type TooltipSide } from "@opal/components"; +import { toPlainString } from "@opal/components/text/InlineMarkdown"; import type { RichStr, WithoutStyles } from "@opal/types"; // --------------------------------------------------------------------------- @@ -28,7 +29,8 @@ interface DisabledProps extends WithoutStyles< /** * Tooltip content shown on hover when disabled. Implies `allowClick` so that * the tooltip trigger can receive pointer events. Supports inline markdown - * via `markdown()`. + * via `markdown()`. Screen readers read it as plain text from a hidden live + * region. */ tooltip?: string | RichStr; @@ -44,7 +46,8 @@ interface DisabledProps extends WithoutStyles< /** * Wrapper component that applies baseline disabled CSS (opacity, cursor, - * pointer-events) to its children. + * pointer-events) to its children, and, unless `allowClick` is set, disables + * the form controls inside so the keyboard cannot reach them either. * * Renders a `
` that carries the `data-opal-disabled` attribute so the * CSS rules in `styles.css` take effect on the wrapper and cascade into its @@ -68,10 +71,16 @@ function Disabled({ tooltip, tooltipSide = "right", ref, + children, ...rest }: DisabledProps) { const showTooltip = disabled && tooltip; const enableClick = allowClick || showTooltip; + // The CSS only blocks the pointer. A disabled fieldset also takes the form + // controls inside out of the tab order and stops keyboard input. It is + // always rendered, so toggling `disabled` never remounts the children. + // `allowClick` keeps the children interactive, so it keeps the keyboard. + const blockKeyboard: boolean = Boolean(disabled) && !allowClick; const wrapper = (
+ > + {/* The tooltip only exists while hovered, and nothing inside a disabled + region can take focus to open it, so the reason is also kept here for + assistive technology. It is polite and always rendered, so a change of + reason is announced and unlocking announces nothing. */} + + {disabled && tooltip ? toPlainString(tooltip) : ""} + +
+ {children} +
+
); if (!showTooltip) return wrapper; diff --git a/web/src/components/Field.tsx b/web/src/components/Field.tsx index c570c386be..bc6f859e95 100644 --- a/web/src/components/Field.tsx +++ b/web/src/components/Field.tsx @@ -31,7 +31,7 @@ import { import { Section } from "@/layouts/general-layouts"; import { transformLinkUri } from "@/lib/utils"; import { cn } from "@opal/utils"; -import FileInput from "@/views/admin/connectors/AddConnectorPage/form/inputs/FileInput"; +import FileDropzoneField from "@/refresh-components/form/FileDropzoneField"; import { RichTextSubtext } from "./RichTextSubtext"; import { TypedFile, FILE_TYPE_DEFINITIONS } from "@/lib/connectors/fileTypes"; import { createTypedFile } from "@/lib/connectors/utils"; @@ -421,7 +421,7 @@ export function FileUploadFormField({ return (
- +
); } diff --git a/web/src/i18n/messages/ar.json b/web/src/i18n/messages/ar.json index 662e7dee7d..bf0e349a39 100644 --- a/web/src/i18n/messages/ar.json +++ b/web/src/i18n/messages/ar.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "تهيئة موصل OneDrive", "indexingScope": { - "label": "ملفات OneDrive الخاصة بمن يجب أن يفهرسها Onyx؟", + "label": "ملفات OneDrive المراد فهرستها", "general": { "label": "عام", "description": "فهرسة جميع المستخدمين المؤهلين الذين يمكن لتطبيق Microsoft الوصول إليهم." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "المسارات المستبعدة", - "description": "أنماط Glob المطابقة لكل مسار ملف واسم ملف، مثل '*.tmp' أو 'Archive/*'." + "description": "أنماط Glob المطابقة لكل مسار ملف واسم ملف، مثل `*.tmp` أو `Archive/*`." }, "organizationLinks": { "label": "معاملة روابط المؤسسة كروابط عامة", @@ -2653,9 +2653,6 @@ "title": "فشل تحميل بيانات الاعتماد." }, "field": { - "optional": { - "label": "(اختياري)" - }, "optionalSuffix": { "label": "اختياري" } @@ -2888,24 +2885,15 @@ "title": "خطأ أثناء الإرسال." } }, - "sections": { - "configuration": { - "title": "الإعدادات" - } + "content": { + "title": "محتوى الموصل", + "description": "حدد المحتوى المراد فهرسته لهذا الموصل." }, "selectInput": { "emptyOption": { "label": "اختر خيارًا" } }, - "stringPairList": { - "addButton": { - "label": "إضافة جديد" - }, - "removeButton": { - "tooltip": "إزالة" - } - }, "tabs": { "empty": { "label": "لا توجد تبويبات لعرضها." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "الصق عنوان URL لموقعك. يجب أن تكون واجهة REST API الخاصة بـ {connectorName} قابلة للوصول هناك." + }, "boundFields": { - "advancedOptions": "خيارات متقدمة", "errors": { "missing": "أدخل قيمة.", "invalid": "هذه القيمة غير صالحة: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {اختر ملفات} other {اختر ملفًا}}" + }, + "prompt": "{multiple, select, true {أو اسحب الملفات وأفلتها هنا.} other {أو اسحب ملفًا وأفلته هنا.}}" + }, + "textListField": { + "addButton": { + "label": "إضافة جديد" + }, + "removeButton": { + "tooltip": "إزالة" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "فتح الشريط الجانبي" diff --git a/web/src/i18n/messages/de.json b/web/src/i18n/messages/de.json index 464cb7dfa7..91d6babcb9 100644 --- a/web/src/i18n/messages/de.json +++ b/web/src/i18n/messages/de.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "OneDrive-Connector konfigurieren", "indexingScope": { - "label": "Wessen OneDrive-Dateien soll Onyx indizieren?", + "label": "Zu indexierende OneDrive-Dateien", "general": { "label": "Allgemein", "description": "Alle berechtigten Benutzer indizieren, auf die die Microsoft-Anwendung zugreifen kann." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "Ausgeschlossene Pfade", - "description": "Glob-Muster für Dateipfade und Dateinamen, zum Beispiel '*.tmp' oder 'Archive/*'." + "description": "Glob-Muster für Dateipfade und Dateinamen, zum Beispiel `*.tmp` oder `Archive/*`." }, "organizationLinks": { "label": "Organisationslinks als öffentlich behandeln", @@ -2653,9 +2653,6 @@ "title": "Die Anmeldedaten konnten nicht geladen werden." }, "field": { - "optional": { - "label": "(optional)" - }, "optionalSuffix": { "label": "optional" } @@ -2888,24 +2885,15 @@ "title": "Fehler beim Senden." } }, - "sections": { - "configuration": { - "title": "Konfiguration" - } + "content": { + "title": "Connector-Inhalte", + "description": "Wählen Sie die Inhalte aus, die für diesen Connector indexiert werden sollen." }, "selectInput": { "emptyOption": { "label": "Option auswählen" } }, - "stringPairList": { - "addButton": { - "label": "Neu hinzufügen" - }, - "removeButton": { - "tooltip": "Entfernen" - } - }, "tabs": { "empty": { "label": "Keine Tabs zum Anzeigen." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "Fügen Sie die URL Ihrer Website ein. Die REST-API von {connectorName} muss dort erreichbar sein." + }, "boundFields": { - "advancedOptions": "Erweiterte Optionen", "errors": { "missing": "Geben Sie einen Wert ein.", "invalid": "Dieser Wert ist ungültig: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {Dateien auswählen} other {Datei auswählen}}" + }, + "prompt": "{multiple, select, true {Oder ziehen Sie Dateien hierher.} other {Oder ziehen Sie eine Datei hierher.}}" + }, + "textListField": { + "addButton": { + "label": "Neu hinzufügen" + }, + "removeButton": { + "tooltip": "Entfernen" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "Seitenleiste öffnen" diff --git a/web/src/i18n/messages/en.json b/web/src/i18n/messages/en.json index ef7e46e028..c4f5e939f3 100644 --- a/web/src/i18n/messages/en.json +++ b/web/src/i18n/messages/en.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "Configure OneDrive connector", "indexingScope": { - "label": "Whose OneDrive files should Onyx index?", + "label": "OneDrive files to index", "general": { "label": "General", "description": "Index all eligible users that the Microsoft application can access." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "Excluded Paths", - "description": "Glob patterns matched against each file path and filename, such as '*.tmp' or 'Archive/*'." + "description": "Glob patterns matched against each file path and filename, such as `*.tmp` or `Archive/*`." }, "organizationLinks": { "label": "Treat organization links as public", @@ -2653,9 +2653,6 @@ "title": "Failed to load credentials." }, "field": { - "optional": { - "label": "(optional)" - }, "optionalSuffix": { "label": "optional" } @@ -2888,24 +2885,15 @@ "title": "Error during submission." } }, - "sections": { - "configuration": { - "title": "Configuration" - } + "content": { + "title": "Connector Content", + "description": "Select content to index for this connector." }, "selectInput": { "emptyOption": { "label": "Select an option" } }, - "stringPairList": { - "addButton": { - "label": "Add New" - }, - "removeButton": { - "tooltip": "Remove" - } - }, "tabs": { "empty": { "label": "No tabs to display." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "Paste your site URL. {connectorName}'s REST API must be reachable there." + }, "boundFields": { - "advancedOptions": "Advanced Options", "errors": { "missing": "Enter a value.", "invalid": "This value is not valid: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {Choose files} other {Choose a file}}" + }, + "prompt": "{multiple, select, true {Or drag and drop files here.} other {Or drag and drop a file here.}}" + }, + "textListField": { + "addButton": { + "label": "Add New" + }, + "removeButton": { + "tooltip": "Remove" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "Open Sidebar" diff --git a/web/src/i18n/messages/es.json b/web/src/i18n/messages/es.json index 2d08d3715d..f5f354ee3e 100644 --- a/web/src/i18n/messages/es.json +++ b/web/src/i18n/messages/es.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "Configurar el conector de OneDrive", "indexingScope": { - "label": "¿Qué archivos de OneDrive debe indexar Onyx?", + "label": "Archivos de OneDrive para indexar", "general": { "label": "General", "description": "Indexa todos los usuarios aptos a los que puede acceder la aplicación de Microsoft." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "Rutas excluidas", - "description": "Patrones glob que se comparan con cada ruta y nombre de archivo, como '*.tmp' o 'Archive/*'." + "description": "Patrones glob que se comparan con cada ruta y nombre de archivo, como `*.tmp` o `Archive/*`." }, "organizationLinks": { "label": "Tratar los enlaces de la organización como públicos", @@ -2653,9 +2653,6 @@ "title": "No se pudieron cargar las credenciales." }, "field": { - "optional": { - "label": "(opcional)" - }, "optionalSuffix": { "label": "opcional" } @@ -2888,24 +2885,15 @@ "title": "Error durante el envío." } }, - "sections": { - "configuration": { - "title": "Configuración" - } + "content": { + "title": "Contenido del conector", + "description": "Selecciona el contenido que se indexará para este conector." }, "selectInput": { "emptyOption": { "label": "Selecciona una opción" } }, - "stringPairList": { - "addButton": { - "label": "Añadir uno nuevo" - }, - "removeButton": { - "tooltip": "Quitar" - } - }, "tabs": { "empty": { "label": "No hay pestañas que mostrar." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "Pega la URL de tu sitio. La API REST de {connectorName} debe ser accesible allí." + }, "boundFields": { - "advancedOptions": "Opciones avanzadas", "errors": { "missing": "Introduce un valor.", "invalid": "Este valor no es válido: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {Elegir archivos} other {Elegir un archivo}}" + }, + "prompt": "{multiple, select, true {O arrastra y suelta archivos aquí.} other {O arrastra y suelta un archivo aquí.}}" + }, + "textListField": { + "addButton": { + "label": "Añadir uno nuevo" + }, + "removeButton": { + "tooltip": "Quitar" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "Abrir barra lateral" diff --git a/web/src/i18n/messages/fr.json b/web/src/i18n/messages/fr.json index f4ac556678..5386f5a836 100644 --- a/web/src/i18n/messages/fr.json +++ b/web/src/i18n/messages/fr.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "Configurer le connecteur OneDrive", "indexingScope": { - "label": "Quels fichiers OneDrive Onyx doit-il indexer ?", + "label": "Fichiers OneDrive à indexer", "general": { "label": "Général", "description": "Indexer tous les utilisateurs admissibles auxquels l’application Microsoft peut accéder." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "Chemins exclus", - "description": "Motifs glob appliqués à chaque chemin et nom de fichier, tels que '*.tmp' ou 'Archive/*'." + "description": "Motifs glob appliqués à chaque chemin et nom de fichier, tels que `*.tmp` ou `Archive/*`." }, "organizationLinks": { "label": "Traiter les liens de l’organisation comme publics", @@ -2653,9 +2653,6 @@ "title": "Échec du chargement des identifiants." }, "field": { - "optional": { - "label": "(facultatif)" - }, "optionalSuffix": { "label": "facultatif" } @@ -2888,24 +2885,15 @@ "title": "Erreur lors de l'envoi." } }, - "sections": { - "configuration": { - "title": "Configuration" - } + "content": { + "title": "Contenu du connecteur", + "description": "Sélectionnez le contenu à indexer pour ce connecteur." }, "selectInput": { "emptyOption": { "label": "Sélectionnez une option" } }, - "stringPairList": { - "addButton": { - "label": "Ajouter" - }, - "removeButton": { - "tooltip": "Retirer" - } - }, "tabs": { "empty": { "label": "Aucun onglet à afficher." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "Collez l'URL de votre site. L'API REST de {connectorName} doit y être accessible." + }, "boundFields": { - "advancedOptions": "Options avancées", "errors": { "missing": "Saisissez une valeur.", "invalid": "Cette valeur n’est pas valide : {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {Choisir des fichiers} other {Choisir un fichier}}" + }, + "prompt": "{multiple, select, true {Ou glissez-déposez des fichiers ici.} other {Ou glissez-déposez un fichier ici.}}" + }, + "textListField": { + "addButton": { + "label": "Ajouter" + }, + "removeButton": { + "tooltip": "Retirer" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "Ouvrir la barre latérale" diff --git a/web/src/i18n/messages/ja.json b/web/src/i18n/messages/ja.json index 9c4edb4c81..b582a87553 100644 --- a/web/src/i18n/messages/ja.json +++ b/web/src/i18n/messages/ja.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "OneDrive コネクタを設定", "indexingScope": { - "label": "Onyx でインデックスを作成する OneDrive ファイルの所有者", + "label": "インデックスする OneDrive ファイル", "general": { "label": "一般", "description": "Microsoft アプリケーションがアクセスできるすべての対象ユーザーをインデックス化します。" @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "除外するパス", - "description": "各ファイルパスとファイル名に照合する glob パターン(例: '*.tmp'、'Archive/*')。" + "description": "各ファイルパスとファイル名に照合する glob パターン(例: `*.tmp`、`Archive/*`)。" }, "organizationLinks": { "label": "組織リンクを公開として扱う", @@ -2653,9 +2653,6 @@ "title": "認証情報の読み込みに失敗しました。" }, "field": { - "optional": { - "label": "(任意)" - }, "optionalSuffix": { "label": "任意" } @@ -2888,24 +2885,15 @@ "title": "送信中にエラーが発生しました。" } }, - "sections": { - "configuration": { - "title": "設定" - } + "content": { + "title": "コネクタのコンテンツ", + "description": "このコネクタでインデックスするコンテンツを選択してください。" }, "selectInput": { "emptyOption": { "label": "選択してください" } }, - "stringPairList": { - "addButton": { - "label": "新規追加" - }, - "removeButton": { - "tooltip": "削除" - } - }, "tabs": { "empty": { "label": "表示するタブはありません。" @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "サイトの URL を貼り付けてください。そこで {connectorName} の REST API にアクセスできる必要があります。" + }, "boundFields": { - "advancedOptions": "詳細オプション", "errors": { "missing": "値を入力してください。", "invalid": "この値は無効です: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {ファイルを選択} other {ファイルを選択}}" + }, + "prompt": "{multiple, select, true {またはここにファイルをドラッグ&ドロップしてください。} other {またはここにファイルをドラッグ&ドロップしてください。}}" + }, + "textListField": { + "addButton": { + "label": "新規追加" + }, + "removeButton": { + "tooltip": "削除" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "サイドバーを開く" diff --git a/web/src/i18n/messages/ko.json b/web/src/i18n/messages/ko.json index 391406ef0c..6a46836e4d 100644 --- a/web/src/i18n/messages/ko.json +++ b/web/src/i18n/messages/ko.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "OneDrive 커넥터 구성", "indexingScope": { - "label": "Onyx가 인덱싱할 OneDrive 파일의 소유자를 선택하세요", + "label": "인덱싱할 OneDrive 파일", "general": { "label": "일반", "description": "Microsoft 애플리케이션이 액세스할 수 있는 모든 적격 사용자를 인덱싱합니다." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "제외 경로", - "description": "각 파일 경로와 파일 이름에 적용할 glob 패턴입니다(예: '*.tmp' 또는 'Archive/*')." + "description": "각 파일 경로와 파일 이름에 적용할 glob 패턴입니다(예: `*.tmp` 또는 `Archive/*`)." }, "organizationLinks": { "label": "조직 링크를 공개로 처리", @@ -2653,9 +2653,6 @@ "title": "자격 증명을 불러오지 못했습니다." }, "field": { - "optional": { - "label": "(선택 사항)" - }, "optionalSuffix": { "label": "선택 사항" } @@ -2888,24 +2885,15 @@ "title": "제출 중 오류가 발생했습니다." } }, - "sections": { - "configuration": { - "title": "구성" - } + "content": { + "title": "커넥터 콘텐츠", + "description": "이 커넥터에서 인덱싱할 콘텐츠를 선택하세요." }, "selectInput": { "emptyOption": { "label": "옵션 선택" } }, - "stringPairList": { - "addButton": { - "label": "새로 추가" - }, - "removeButton": { - "tooltip": "제거" - } - }, "tabs": { "empty": { "label": "표시할 탭이 없습니다." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "사이트 URL을 붙여 넣으세요. 해당 위치에서 {connectorName} REST API에 액세스할 수 있어야 합니다." + }, "boundFields": { - "advancedOptions": "고급 옵션", "errors": { "missing": "값을 입력하세요.", "invalid": "이 값은 올바르지 않습니다: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {파일 선택} other {파일 선택}}" + }, + "prompt": "{multiple, select, true {또는 파일을 여기로 끌어다 놓으세요.} other {또는 파일을 여기로 끌어다 놓으세요.}}" + }, + "textListField": { + "addButton": { + "label": "새로 추가" + }, + "removeButton": { + "tooltip": "제거" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "사이드바 열기" diff --git a/web/src/i18n/messages/pt.json b/web/src/i18n/messages/pt.json index aeb6e03e7e..9e984b521e 100644 --- a/web/src/i18n/messages/pt.json +++ b/web/src/i18n/messages/pt.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "Configurar o conector do OneDrive", "indexingScope": { - "label": "Quais arquivos do OneDrive o Onyx deve indexar?", + "label": "Arquivos do OneDrive para indexar", "general": { "label": "Geral", "description": "Indexe todos os usuários qualificados que o aplicativo da Microsoft pode acessar." @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "Caminhos excluídos", - "description": "Padrões glob comparados com cada caminho e nome de arquivo, como '*.tmp' ou 'Archive/*'." + "description": "Padrões glob comparados com cada caminho e nome de arquivo, como `*.tmp` ou `Archive/*`." }, "organizationLinks": { "label": "Tratar links da organização como públicos", @@ -2653,9 +2653,6 @@ "title": "Falha ao carregar as credenciais." }, "field": { - "optional": { - "label": "(opcional)" - }, "optionalSuffix": { "label": "opcional" } @@ -2888,24 +2885,15 @@ "title": "Erro durante o envio." } }, - "sections": { - "configuration": { - "title": "Configuração" - } + "content": { + "title": "Conteúdo do conector", + "description": "Selecione o conteúdo a ser indexado para este conector." }, "selectInput": { "emptyOption": { "label": "Selecione uma opção" } }, - "stringPairList": { - "addButton": { - "label": "Adicionar novo" - }, - "removeButton": { - "tooltip": "Remover" - } - }, "tabs": { "empty": { "label": "Nenhuma aba para mostrar." @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "Cole a URL do seu site. A API REST do {connectorName} deve estar acessível nele." + }, "boundFields": { - "advancedOptions": "Opções avançadas", "errors": { "missing": "Insira um valor.", "invalid": "Este valor não é válido: {detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {Escolher arquivos} other {Escolher um arquivo}}" + }, + "prompt": "{multiple, select, true {Ou arraste e solte arquivos aqui.} other {Ou arraste e solte um arquivo aqui.}}" + }, + "textListField": { + "addButton": { + "label": "Adicionar novo" + }, + "removeButton": { + "tooltip": "Remover" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "Abrir barra lateral" diff --git a/web/src/i18n/messages/zh.json b/web/src/i18n/messages/zh.json index dd89582fa7..2d68edae3e 100644 --- a/web/src/i18n/messages/zh.json +++ b/web/src/i18n/messages/zh.json @@ -2530,7 +2530,7 @@ "oneDrive": { "description": "配置 OneDrive 连接器", "indexingScope": { - "label": "Onyx 应为哪些用户的 OneDrive 文件建立索引?", + "label": "要索引的 OneDrive 文件", "general": { "label": "常规", "description": "为 Microsoft 应用程序可以访问的所有符合条件的用户建立索引。" @@ -2546,7 +2546,7 @@ }, "excludedPaths": { "label": "排除的路径", - "description": "与每个文件路径和文件名匹配的 glob 模式,例如“*.tmp”或“Archive/*”。" + "description": "与每个文件路径和文件名匹配的 glob 模式,例如`*.tmp`或`Archive/*`。" }, "organizationLinks": { "label": "将组织链接视为公开链接", @@ -2653,9 +2653,6 @@ "title": "加载凭据失败。" }, "field": { - "optional": { - "label": "(可选)" - }, "optionalSuffix": { "label": "可选" } @@ -2888,24 +2885,15 @@ "title": "提交时出错。" } }, - "sections": { - "configuration": { - "title": "配置" - } + "content": { + "title": "连接器内容", + "description": "选择此连接器要索引的内容。" }, "selectInput": { "emptyOption": { "label": "选择一个选项" } }, - "stringPairList": { - "addButton": { - "label": "新增" - }, - "removeButton": { - "tooltip": "移除" - } - }, "tabs": { "empty": { "label": "没有可显示的标签页。" @@ -2915,8 +2903,10 @@ "confluenceCloud": "Confluence Cloud", "confluenceDataCenter": "Confluence Data Center" }, + "subDescriptions": { + "siteUrl": "粘贴你的站点 URL。{connectorName} 的 REST API 必须能在该地址访问。" + }, "boundFields": { - "advancedOptions": "高级选项", "errors": { "missing": "请输入一个值。", "invalid": "此值无效:{detail}" @@ -11338,6 +11328,20 @@ } }, "common": { + "fileDropzoneField": { + "chooseButton": { + "label": "{multiple, select, true {选择文件} other {选择文件}}" + }, + "prompt": "{multiple, select, true {或将文件拖放到此处。} other {或将文件拖放到此处。}}" + }, + "textListField": { + "addButton": { + "label": "新增" + }, + "removeButton": { + "tooltip": "移除" + } + }, "adminChrome": { "openSidebar": { "ariaLabel": "打开侧边栏" diff --git a/web/src/lib/connectors/connectorConfigFields.test.tsx b/web/src/lib/connectors/connectorConfigFields.test.tsx new file mode 100644 index 0000000000..3dbabe0181 --- /dev/null +++ b/web/src/lib/connectors/connectorConfigFields.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@tests/setup/test-utils"; +import { Formik } from "formik"; +import ConnectorConfigFields from "@/views/admin/connectors/AddConnectorPage/form/ConnectorConfigFields"; +import { connectorConfigs } from "@/lib/connectors/connectors"; +import { createConnectorInitialValues } from "@/lib/connectors/utils"; +import { ValidSources } from "@/lib/connectors/types/source"; +import type { Credential } from "@/lib/credentials/types"; + +// Field descriptions render as Opal markdown. Under Jest, react-markdown's +// default export loads as a module object, so render the source text as is. +jest.mock("react-markdown", () => ({ + __esModule: true, + default: ({ children }: { children?: string }) => children ?? null, +})); + +function credentialWith(json: Record): Credential { + return { id: 1, credential_json: json } as unknown as Credential; +} + +function renderDriveFields(currentCredential: Credential | null) { + const values = createConnectorInitialValues(ValidSources.GoogleDrive); + render( + {}}> + + + ); +} + +test("an advanced field hides when its own visibleCondition fails", () => { + renderDriveFields(credentialWith({ google_tokens: "oauth" })); + + expect(screen.queryByText("Specific User Emails")).not.toBeInTheDocument(); +}); + +test("an advanced field shows when its visibleCondition passes", () => { + renderDriveFields(credentialWith({ google_service_account_key: "key" })); + + expect(screen.getByText("Specific User Emails")).toBeInTheDocument(); +}); diff --git a/web/src/lib/connectors/connectors.tsx b/web/src/lib/connectors/connectors.tsx index beadb643e9..b408b42a71 100644 --- a/web/src/lib/connectors/connectors.tsx +++ b/web/src/lib/connectors/connectors.tsx @@ -313,7 +313,7 @@ export const connectorConfigs: Record< { type: "tab", name: "github_mode", - label: "What should we index from GitHub?", + label: "GitHub content to index", optional: true, tabs: [ { @@ -348,8 +348,8 @@ export const connectorConfigs: Record< }, { type: "checkbox", - query: "Include pull requests?", - label: "Include pull requests?", + query: "Include pull requests", + label: "Include pull requests", description: "Index pull requests from repositories", name: "include_prs", optional: true, @@ -357,7 +357,7 @@ export const connectorConfigs: Record< { type: "checkbox", query: "Include issues?", - label: "Include Issues?", + label: "Include Issues", name: "include_issues", description: "Index issues from repositories", optional: true, @@ -365,7 +365,7 @@ export const connectorConfigs: Record< { type: "checkbox", query: "Include documents?", - label: "Include Documents?", + label: "Include Documents", name: "include_files", description: "Index text-based documents (markdown, text, etc.) from repositories", @@ -475,7 +475,7 @@ export const connectorConfigs: Record< { type: "tab", name: "bitbucket_mode", - label: "What should be indexed from Bitbucket?", + label: "Bitbucket content to index", optional: true, tabs: [ { @@ -548,7 +548,6 @@ export const connectorConfigs: Record< { type: "tab", name: "indexing_scope", - label: "How should we index your Google Drive?", optional: true, tabs: [ { @@ -557,7 +556,7 @@ export const connectorConfigs: Record< fields: [ { type: "checkbox", - label: "Include shared drives?", + label: "Include shared drives", description: (currentCredential) => { return currentCredential?.credential_json?.google_tokens ? "This will allow Onyx to index everything in the shared drives you have access to." @@ -570,8 +569,8 @@ export const connectorConfigs: Record< type: "checkbox", label: (currentCredential) => { return currentCredential?.credential_json?.google_tokens - ? "Include My Drive?" - : "Include Everyone's My Drive?"; + ? "Include My Drive" + : "Include Everyone's My Drive"; }, description: (currentCredential) => { return currentCredential?.credential_json?.google_tokens @@ -585,7 +584,7 @@ export const connectorConfigs: Record< type: "checkbox", description: "This will allow Onyx to index all files shared with you.", - label: "Include All Files Shared With You?", + label: "Include All Files Shared With You", name: "include_files_shared_with_me", visibleCondition: (values, currentCredential) => currentCredential?.credential_json?.google_tokens, @@ -650,7 +649,7 @@ export const connectorConfigs: Record< }, { type: "checkbox", - label: "Hide domain link-only files?", + label: "Hide domain link-only files", description: "When enabled, Onyx skips files that are shared broadly (domain or public) but require the link to access.", name: "exclude_domain_link_only", @@ -719,21 +718,23 @@ export const connectorConfigs: Record< } return false; }, - description: - "The base URL of your Confluence instance (e.g., https://your-domain.atlassian.net/wiki)", + placeholder: "https://your-domain.atlassian.net/wiki", + subDescription: "siteUrl", }, { type: "checkbox", query: "Using scoped token?", - label: "Using scoped token", + label: "This is an API token with scopes", + description: + "Scoped tokens require routing through Atlassian's API gateway. Leave off for classic tokens.", name: "scoped_token", optional: true, default: false, + asCheckbox: true, }, { type: "tab", name: "indexing_scope", - label: "How Should We Index Your Confluence?", optional: true, tabs: [ { @@ -833,7 +834,6 @@ export const connectorConfigs: Record< { type: "tab", name: "indexing_scope", - label: "How Should We Index Your Jira?", optional: true, tabs: [ { @@ -932,15 +932,9 @@ export const connectorConfigs: Record< isTextArea: true, description: "Enter a JSON configuration that precisely defines which fields and child objects to index. This gives you complete control over the data structure." + - "\n\nExample:" + - "\n{" + - '\n "Account": {' + - '\n "fields": ["Id", "Name", "Industry"],' + - '\n "associations": {' + - '\n "Contact": ["Id", "FirstName", "LastName", "Email"]' + - "\n }" + - "\n }" + - "\n}" + + "\n\nExample: " + + '`{"Account": {"fields": ["Id", "Name", "Industry"], ' + + '"associations": {"Contact": ["Id", "FirstName", "LastName", "Email"]}}}`' + `\n\n[See our docs](${DOCS_ADMINS_PATH}/connectors/official/salesforce) for more details.`, }, ], @@ -960,12 +954,11 @@ export const connectorConfigs: Record< label: "Sites", name: "sites", optional: true, - description: `• If no sites are specified, all sites in your organization will be indexed (Sites.Read.All permission required). -• Specifying 'https://onyxai.sharepoint.com/sites/support' for example only indexes this site. -• Specifying 'https://onyxai.sharepoint.com/sites/support/subfolder' for example only indexes this folder. -• To index users' personal sites, use the [OneDrive connector](${DOCS_ADMINS_PATH}/connectors/official/onedrive). -• Specifying sites currently works for SharePoint instances using English, Spanish, or German. Contact the Onyx team if you need another language supported. -`, + description: `- If no sites are specified, all sites in your organization will be indexed (\`Sites.Read.All\` permission required). +- Specifying \`https://onyxai.sharepoint.com/sites/support\` for example only indexes this site. +- Specifying \`https://onyxai.sharepoint.com/sites/support/subfolder\` for example only indexes this folder. +- To index users' personal sites, use the [OneDrive connector](${DOCS_ADMINS_PATH}/connectors/official/onedrive). +- Specifying sites currently works for SharePoint instances using English, Spanish, or German. Contact the Onyx team if you need another language supported.`, }, ], advanced_values: [ @@ -991,7 +984,7 @@ export const connectorConfigs: Record< }, { type: "checkbox", - label: "Treat sharing links as public?", + label: "Treat sharing links as public", description: "When enabled, documents with a sharing link (anonymous or organization-wide) " + "are treated as public (visible to all Onyx users). " + @@ -1009,8 +1002,8 @@ export const connectorConfigs: Record< description: "Site URLs or glob patterns to exclude from indexing. " + "Matched sites will never be indexed, even if they appear in the sites list above. " + - "Examples: 'https://contoso.sharepoint.com/sites/archive' (exact), " + - "'*://*/sites/archive-*' (glob pattern).", + "Examples: `https://contoso.sharepoint.com/sites/archive` (exact), " + + "`*://*/sites/archive-*` (glob pattern).", }, { type: "list", @@ -1021,7 +1014,7 @@ export const connectorConfigs: Record< description: "Glob patterns for file paths to exclude from indexing within document libraries. " + "Patterns are matched against both the full relative path and the filename. " + - "Examples: '*.tmp' (temp files), '~$*' (Office lock files), 'Archive/*' (folder).", + "Examples: `*.tmp` (temp files), `~$*` (Office lock files), `Archive/*` (folder).", }, { type: "text", @@ -1286,7 +1279,7 @@ export const connectorConfigs: Record< { type: "tab", name: "indexing_scope", - label: "What should we index from Drupal Wiki?", + label: "Drupal Wiki content to index", optional: true, tabs: [ { @@ -1377,7 +1370,8 @@ export const connectorConfigs: Record< label: "Enable Channel Regex", name: "channel_regex_enabled", description: `If enabled, we will treat the "channels" specified above as regular expressions. A channel's messages will be pulled in by the connector if the name of the channel fully matches any of the specified regular expressions. -For example, specifying .*-support.* as a "channel" will cause the connector to include any channels with "-support" in the name.`, + +For example, specifying \`.*-support.*\` as a "channel" will cause the connector to include any channels with "-support" in the name.`, optional: true, }, { @@ -1396,7 +1390,8 @@ For example, specifying .*-support.* as a "channel" will cause the connector to label: "Enable Exclude Channel Regex", name: "exclude_channel_regex_enabled", description: `If enabled, we will treat the "channels to exclude" specified above as regular expressions. A channel will be excluded if its name fully matches any of the specified regular expressions. -For example, specifying .*-alerts as a "channel to exclude" will cause the connector to skip any channels ending in "-alerts".`, + +For example, specifying \`.*-alerts\` as a "channel to exclude" will cause the connector to skip any channels ending in "-alerts".`, optional: true, }, { @@ -2056,8 +2051,8 @@ For example, specifying .*-alerts as a "channel to exclude" will cause the conne { name: "business_plus", value: "business_plus" }, ], description: - "Sets how fast this connector is allowed to call Zoom. Pick pro on " + - "a Pro account, and business_plus on Business, Education, " + + "Sets how fast this connector is allowed to call Zoom. Pick `pro` on " + + "a Pro account, and `business_plus` on Business, Education, " + "Enterprise or Partner.", }, ], @@ -2138,7 +2133,7 @@ For example, specifying .*-alerts as a "channel to exclude" will cause the conne { type: "tab", name: "airtable_scope", - label: "What should we index from Airtable?", + label: "Airtable content to index", optional: true, tabs: [ { @@ -2197,7 +2192,7 @@ For example, specifying .*-alerts as a "channel to exclude" will cause the conne { type: "tab", name: "highspot_scope", - label: "What should we index from Highspot?", + label: "Highspot content to index", optional: true, tabs: [ { diff --git a/web/src/lib/connectors/stringPairValidation.test.ts b/web/src/lib/connectors/stringPairValidation.test.ts new file mode 100644 index 0000000000..ee6d7d3ec8 --- /dev/null +++ b/web/src/lib/connectors/stringPairValidation.test.ts @@ -0,0 +1,51 @@ +import { + createConnectorInitialValues, + createConnectorValidationSchema, +} from "@/lib/connectors/utils"; +import { ValidSources } from "@/lib/connectors/types/source"; + +const messages = { + stringPairEmptyKey: "Key cannot be empty", + stringPairDuplicateKey: "Duplicate key", +}; + +function webValues(urlRewrites: Record[]) { + return { + ...createConnectorInitialValues(ValidSources.Web), + name: "web", + base_url: "https://docs.example.com", + url_rewrites: urlRewrites, + }; +} + +function validate(urlRewrites: Record[]) { + return createConnectorValidationSchema( + ValidSources.Web, + false, + messages + ).validateAt("url_rewrites", webValues(urlRewrites)); +} + +test("URL rewrites with distinct source prefixes pass", async () => { + await expect( + validate([ + { source: "https://mirror.example.com", target: "https://a.example.com" }, + { source: "https://cache.example.com", target: "https://b.example.com" }, + ]) + ).resolves.toBeTruthy(); +}); + +test("a URL rewrite without a source prefix fails", async () => { + await expect( + validate([{ source: " ", target: "https://a.example.com" }]) + ).rejects.toThrow("Key cannot be empty"); +}); + +test("two URL rewrites with the same source prefix fail", async () => { + await expect( + validate([ + { source: "https://mirror.example.com", target: "https://a.example.com" }, + { source: "https://mirror.example.com", target: "https://b.example.com" }, + ]) + ).rejects.toThrow("Duplicate key"); +}); diff --git a/web/src/lib/connectors/types/form.ts b/web/src/lib/connectors/types/form.ts index afdc51177f..103e6d6a35 100644 --- a/web/src/lib/connectors/types/form.ts +++ b/web/src/lib/connectors/types/form.ts @@ -63,11 +63,18 @@ export interface StringPairListOption extends Option { rightPlaceholder?: string; } +/** A key under `admin.connectorsList.subDescriptions` in the message catalog. */ +export type TextSubDescriptionKey = "siteUrl"; + export interface TextOption extends Option { type: "text"; default?: string; initial?: string | ((currentCredential: Credential | null) => string); isTextArea?: boolean; + /** Example value shown in the empty input. */ + placeholder?: string; + /** Text below the input. It gets the connector's name as `connectorName`. */ + subDescription?: TextSubDescriptionKey; } export interface NumberOption extends Option { @@ -85,6 +92,8 @@ export interface BooleanOption extends Option { initial?: (currentCredential: Credential | null) => boolean | undefined; /** Shows the value as two tabs with these labels instead of a checkbox. */ tabLabels?: { true: CheckboxTabLabelKey; false: CheckboxTabLabelKey }; + /** Shows a checkbox instead of the standard switch. */ + asCheckbox?: boolean; } export interface FileOption extends Option { @@ -108,8 +117,10 @@ export type ConnectorValueField = | FileOption | StringTabOption; -export interface TabOption extends Option { +export interface TabOption extends Omit { type: "tab"; + /** Heading above the tabs. Leave it out when the tab labels say enough. */ + label?: Option["label"]; defaultTab?: string; tabs: { label: string; diff --git a/web/src/lib/connectors/utils.ts b/web/src/lib/connectors/utils.ts index 7c381293a8..cafff4c0b6 100644 --- a/web/src/lib/connectors/utils.ts +++ b/web/src/lib/connectors/utils.ts @@ -92,6 +92,49 @@ export function splitCredentialBoundFields( interface ConnectorValidationMessages { oneDriveUsersRequired?: string; specificGroupsRequired?: string; + /** A string-pair row with an empty left key. */ + stringPairEmptyKey?: string; + /** Two string-pair rows with the same left key. */ + stringPairDuplicateKey?: string; +} + +/** Each row's trimmed value under `leftKey`, or "" when it has none. */ +function stringPairLeftKeys( + rows: unknown[] | undefined, + leftKey: string +): string[] { + return (rows ?? []).map((row) => { + const entry: [string, unknown] | undefined = + typeof row === "object" && row !== null + ? Object.entries(row).find(([key]) => key === leftKey) + : undefined; + return String(entry?.[1] ?? "").trim(); + }); +} + +/** + * A string-pair list, such as URL rewrite rules. Each row needs a left key, + * and no two rows may share one, as the InputKeyValue editor shows. + */ +function stringPairListSchema( + leftKey: string, + messages: ConnectorValidationMessages +): Yup.Schema { + return Yup.array() + .of(Yup.object()) + .test( + "non-empty-keys", + messages.stringPairEmptyKey ?? "Key cannot be empty", + (rows) => stringPairLeftKeys(rows, leftKey).every((key) => key !== "") + ) + .test( + "unique-keys", + messages.stringPairDuplicateKey ?? "Duplicate key", + (rows) => { + const keys: string[] = stringPairLeftKeys(rows, leftKey); + return new Set(keys).size === keys.length; + } + ); } const buildInitialValuesForFields = ( @@ -156,7 +199,7 @@ export function createConnectorValidationSchema( : field.type === "multiselect" ? Yup.array().of(Yup.string()) : field.type === "string_pair_list" - ? Yup.array().of(Yup.object()) + ? stringPairListSchema(field.leftKey, messages) : field.type === "checkbox" ? Yup.boolean() : field.type === "file" diff --git a/web/src/lib/connectors/zoomConnectorConfig.test.tsx b/web/src/lib/connectors/zoomConnectorConfig.test.tsx index 82e95fd24c..cce4c21f0e 100644 --- a/web/src/lib/connectors/zoomConnectorConfig.test.tsx +++ b/web/src/lib/connectors/zoomConnectorConfig.test.tsx @@ -9,6 +9,13 @@ import { } from "@/lib/connectors/utils"; import { ValidSources } from "@/lib/connectors/types/source"; +// Field descriptions render as Opal markdown. Under Jest, react-markdown's +// default export loads as a module object, so render the source text as is. +jest.mock("react-markdown", () => ({ + __esModule: true, + default: ({ children }: { children?: string }) => children ?? null, +})); + function ZoomForm() { const config = connectorConfigs.zoom; return ( @@ -80,8 +87,10 @@ test("the plan select offers exactly the tiers the backend parses", async () => test("the rate limit share is an advanced number field", () => { render(); - const rateLimit = screen.getByRole("spinbutton"); - expect(rateLimit).toHaveAttribute("name", "rate_limit_percent"); + const rateLimit = screen.getByLabelText(/Zoom API Rate Limit/); + expect(rateLimit).toHaveAttribute("id", "rate_limit_percent"); + // Connector number fields allow -1, so the pattern takes a leading minus. + expect(rateLimit).toHaveAttribute("pattern", "-?[0-9]*"); }); test("the plan has to be chosen", async () => { diff --git a/web/src/refresh-components/form/FileDropzoneField.tsx b/web/src/refresh-components/form/FileDropzoneField.tsx new file mode 100644 index 0000000000..7625f341c0 --- /dev/null +++ b/web/src/refresh-components/form/FileDropzoneField.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useField } from "formik"; +import { useTranslations } from "next-intl"; +import { useDropzone } from "react-dropzone"; +import { Button, Text } from "@opal/components"; +import { SvgUploadCloud } from "@opal/icons"; +import { cn } from "@opal/utils"; + +export interface FileDropzoneFieldProps { + /** The Formik field name. Also the file input's id. */ + name: string; + /** Takes many files (`File[]`) instead of one (`File | null`). */ + multiple?: boolean; + /** Takes one .zip file. */ + isZip?: boolean; + disabled?: boolean; + /** Names the file input when no label points at it. */ + "aria-label"?: string; +} + +/** + * Formik-bound drop area with a file picker button. The field holds a + * `File[]`, or one `File` (or null) when it takes a single file. Files stay + * binary, unlike Opal's InputFile, which reads them as text. + */ +export default function FileDropzoneField({ + name, + multiple = true, + isZip = false, + disabled = false, + "aria-label": ariaLabel, +}: FileDropzoneFieldProps) { + const t = useTranslations("common.fileDropzoneField"); + const [field, , helpers] = useField(name); + const single: boolean = isZip || !multiple; + const selectedFiles: File[] = Array.isArray(field.value) + ? field.value + : field.value + ? [field.value] + : []; + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + disabled, + multiple: !single, + noClick: true, + noKeyboard: true, + accept: isZip ? { "application/zip": [".zip"] } : undefined, + onDrop: (files) => { + void helpers.setTouched(true, false); + void helpers.setValue(single ? (files[0] ?? null) : files); + }, + }); + + const chooseLabel: string = t("chooseButton.label", { + multiple: String(!single), + }); + + return ( +
+ + + + {selectedFiles.length > 0 + ? selectedFiles.map((file) => file.name).join(", ") + : t("prompt", { multiple: String(!single) })} + +
+ ); +} diff --git a/web/src/refresh-components/form/TextListField.tsx b/web/src/refresh-components/form/TextListField.tsx new file mode 100644 index 0000000000..c6ef57df3b --- /dev/null +++ b/web/src/refresh-components/form/TextListField.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { + ArrayHelpers, + ErrorMessage, + FieldArray, + getIn, + useFormikContext, +} from "formik"; +import { useTranslations } from "next-intl"; +import { Button } from "@opal/components"; +import { InputErrorText, Section } from "@opal/layouts"; +import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons"; +import InputTypeInField from "@/refresh-components/form/InputTypeInField"; + +interface StableRowKeys { + /** One React key per row, in row order. */ + keys: number[]; + /** Drops the key of the row at `index`. Call it with the row's removal. */ + removeKey: (index: number) => void; +} + +/** + * Stable per-row keys, so removing a middle row doesn't shift native input + * state (focus, autofill) onto the row that takes its index. Index keys + * would; content-derived keys would remount the row on every keystroke. + */ +function useStableRowKeys(rowCount: number): StableRowKeys { + const [state, setState] = useState<{ keys: number[]; nextKey: number }>({ + keys: Array.from({ length: rowCount }, (_, index) => index), + nextKey: rowCount, + }); + + if (state.keys.length < rowCount) { + const keysToAdd: number = rowCount - state.keys.length; + setState({ + keys: [ + ...state.keys, + ...Array.from( + { length: keysToAdd }, + (_, index) => state.nextKey + index + ), + ], + nextKey: state.nextKey + keysToAdd, + }); + } else if (state.keys.length > rowCount) { + setState({ keys: state.keys.slice(0, rowCount), nextKey: state.nextKey }); + } + + function removeKey(index: number) { + setState((prev) => ({ + keys: prev.keys.filter((_, i) => i !== index), + nextKey: prev.nextKey, + })); + } + + return { keys: state.keys, removeKey }; +} + +export interface TextListFieldProps { + /** The Formik field name. Its value is a `string[]`. */ + name: string; + placeholder?: string; + disabled?: boolean; +} + +/** + * Formik-bound list of strings: one text input per row, with add and remove + * buttons. Shows each row's error under it, and an error on the whole list + * under the add button. + */ +export default function TextListField({ + name, + placeholder, + disabled, +}: TextListFieldProps) { + const t = useTranslations("common.textListField"); + const { values, errors, touched } = + useFormikContext>(); + const rawItems: unknown = getIn(values, name); + const items: unknown[] = Array.isArray(rawItems) ? rawItems : []; + const rowKeys: StableRowKeys = useStableRowKeys(items.length); + + const listError: unknown = getIn(errors, name); + const listErrorText: string | undefined = + getIn(touched, name) && typeof listError === "string" + ? listError + : undefined; + + return ( + ( +
+ {items.map((_, index) => ( +
+
+ +
+ ( + {msg} + )} + /> +
+ ))} + + + + {listErrorText && ( + {listErrorText} + )} +
+ )} + /> + ); +} diff --git a/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx b/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx index 48cf26cdb3..42452ad0d6 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx @@ -1,6 +1,18 @@ "use client"; -import { IconLoader } from "@opal/loaders"; +import { PageLoader, IconLoader } from "@opal/loaders"; +import { + IllustrationContent, + PageCenter, + Section, + SettingsLayouts, + toast, +} from "@opal/layouts"; +import { SvgPlugBroken } from "@opal/illustrations"; +import { escapeMarkdown, markdown } from "@opal/utils"; +import { Divider, MessageCard, Button } from "@opal/components"; +import { SvgArrowExchange } from "@opal/icons"; +import { Disabled } from "@opal/core"; import { usePermissionAuthority } from "@/lib/permissions/hooks"; import { Permission } from "@/lib/types"; import { @@ -14,9 +26,9 @@ import { linkCredential } from "@/lib/credentials/svc"; import { CredentialsConfigurer } from "@/lib/credentials/components/CredentialsConfigurer"; import { submitFiles } from "@/lib/connectors/svc"; import { submitGoogleSite } from "@/lib/connectors/svc"; -import AdvancedFormPage from "@/views/admin/connectors/AddConnectorPage/form/Advanced"; -import ConnectorSettings from "@/views/admin/connectors/AddConnectorPage/form/ConnectorSettings"; -import DynamicConnectionForm from "@/views/admin/connectors/AddConnectorPage/form/DynamicConnectorCreationForm"; +import ScheduleSection from "@/views/admin/connectors/AddConnectorPage/sections/ScheduleSection"; +import ConnectorSettingsSection from "@/views/admin/connectors/AddConnectorPage/sections/ConnectorSettingsSection"; +import ConnectorContentSection from "@/views/admin/connectors/AddConnectorPage/sections/ConnectorContentSection"; import CredentialBoundFields from "@/views/admin/connectors/AddConnectorPage/form/CredentialBoundFields"; import { BoundFieldsGate } from "@/views/admin/connectors/AddConnectorPage/form/BoundFieldsGate"; import { @@ -45,8 +57,6 @@ import type { ConnectorBase, } from "@/lib/connectors/types"; import { useSettings } from "@/lib/settings/hooks"; -import { Card, Divider, MessageCard } from "@opal/components"; -import { Disabled } from "@opal/core"; import { useGmailCredentials, useCredentialLoad, @@ -54,20 +64,7 @@ import { } from "@/lib/credentials/hooks"; import { Formik } from "formik"; import { useRouter } from "next/navigation"; -import { Button } from "@opal/components"; -import { - Content, - IllustrationContent, - PageCenter, - Section, - SettingsLayouts, - toast, -} from "@opal/layouts"; -import { PageLoader } from "@opal/loaders"; -import { SvgPlugBroken } from "@opal/illustrations"; -import { escapeMarkdown, markdown } from "@opal/utils"; import { deleteConnector } from "@/lib/connector"; -import { SvgArrowExchange } from "@opal/icons"; import { useTranslations } from "next-intl"; import { SYNC_RESTRICTED_ACCESS_TYPE, @@ -142,13 +139,15 @@ export async function submitConnector( } } -export default function AddConnector({ - connector, -}: { +export interface AddConnectorProps { connector: ConfigurableSources; -}) { +} + +export default function AddConnector({ connector }: AddConnectorProps) { const t = useTranslations("admin.connectorsList"); const oneDriveT = useTranslations("admin.connectorsList.oneDrive"); + // The string-pair editor (InputKeyValue) shows these same messages. + const keyValueT = useTranslations("opal.keyValue"); const router = useRouter(); const settings = useSettings(); const defaultPruneFreqHours = settings.default_pruning_freq @@ -266,6 +265,8 @@ export default function AddConnector({ specificGroupsRequired: t( "settings.documentAccess.specificGroups.required" ), + stringPairEmptyKey: keyValueT("emptyKey"), + stringPairDuplicateKey: keyValueT("duplicateKey"), } )} onSubmit={async (values) => { @@ -628,7 +629,7 @@ export default function AddConnector({ {!noCredentials && ( )} @@ -643,87 +644,49 @@ export default function AddConnector({ /> )} - {/* The wizard could not reach these sections without a - valid credential; on one page they stay disabled until - the credential and the bound fields are valid instead. */} - - - {/* A disabled fieldset also takes the controls out of - the tab order; the wrapper above only blocks the - pointer. */} -
-
- {/* Announces why the configuration is locked - when the reason changes. */} -
- -
- -
-
-
-
+ {(!noCredentials || hasVisibleBoundFields) && ( + + )} - + {/* The wizard could not reach these sections without a + valid credential; on one page they stay locked, under one + Disabled that blocks pointer and keyboard, until the + credential and the bound fields are valid. */} -
- + -
-
- {connector !== "file" && ( - <> - -
- + + {connector !== "file" && ( + <> + + -
-
- - )} + + )} + + )} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/BoundFieldsGate.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/BoundFieldsGate.tsx index 75c8abc991..310efdcc83 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/form/BoundFieldsGate.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/form/BoundFieldsGate.tsx @@ -37,7 +37,7 @@ function labelOf( ): string { return typeof field.label === "function" ? field.label(credential) - : field.label; + : (field.label ?? field.name); } /** diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/ConnectorConfigFields.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/ConnectorConfigFields.tsx new file mode 100644 index 0000000000..3867b48384 --- /dev/null +++ b/web/src/views/admin/connectors/AddConnectorPage/form/ConnectorConfigFields.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; +import CredentialSubText from "@/lib/credentials/components/CredentialFields"; +import type { ConnectionConfiguration } from "@/lib/connectors/types"; +import type { ConfigurableSources } from "@/lib/connectors/types/source"; +import type { Credential } from "@/lib/credentials/types"; +import { RenderField } from "./FieldRendering"; +import { useFormikContext } from "formik"; + +type ConnectorField = ConnectionConfiguration["values"][number]; + +export interface ConnectorConfigFieldsProps { + config: ConnectionConfiguration; + values: any; + connector: ConfigurableSources; + currentCredential: Credential | null; +} + +export default function ConnectorConfigFields({ + config, + values, + connector, + currentCredential, +}: ConnectorConfigFieldsProps) { + const { setFieldValue } = useFormikContext(); // Get Formik's context functions + + const [connectorNameInitialized, setConnectorNameInitialized] = + useState(false); + + let initialConnectorName = ""; + if (config.initialConnectorName) { + initialConnectorName = + currentCredential?.credential_json?.[config.initialConnectorName] ?? ""; + } + + useEffect(() => { + const field_value = values["name"]; + if (initialConnectorName && !connectorNameInitialized && !field_value) { + setFieldValue("name", initialConnectorName); + setConnectorNameInitialized(true); + } + }, [initialConnectorName, setFieldValue, values]); + + // Advanced fields are all optional, so they sit in the same flat list + // instead of behind a toggle. A field's own visibleCondition still applies. + const showAdvanced: boolean = + !config.advancedValuesVisibleCondition || + config.advancedValuesVisibleCondition(values, currentCredential); + const visibleFields: ConnectorField[] = [ + ...config.values, + ...(showAdvanced ? config.advanced_values : []), + ].filter( + (field) => + !field.hidden && + (!field.visibleCondition || + field.visibleCondition(values, currentCredential)) + ); + + return ( + <> + {config.subtext && ( + {config.subtext} + )} + + {visibleFields.map((field) => ( + + ))} + + ); +} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/CredentialBoundFields.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/CredentialBoundFields.tsx index f9f00e6ce5..9cb6151541 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/form/CredentialBoundFields.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/form/CredentialBoundFields.tsx @@ -1,6 +1,6 @@ import { useTranslations } from "next-intl"; import { Section } from "@opal/layouts"; -import { Collapsible, Text } from "@opal/components"; +import { Text } from "@opal/components"; import type { CredentialBindingFieldError } from "@/lib/connectors/bindingGate"; import type { ConfigurableSources } from "@/lib/connectors/types/source"; import type { ConnectionConfiguration } from "@/lib/connectors/types"; @@ -12,9 +12,9 @@ type ConnectorField = ConnectionConfiguration["values"][number]; export interface CredentialBoundFieldsProps { /** Bound fields shown at all times. */ fields: ConnectorField[]; - /** Bound fields shown behind the advanced options toggle. */ + /** Advanced bound fields, shown after the others in the same list. */ advancedFields: ConnectorField[]; - /** Shows the advanced toggle, as `advancedValuesVisibleCondition` does. */ + /** Shows the advanced fields, as `advancedValuesVisibleCondition` does. */ showAdvancedFields: boolean; values: Record; connector: ConfigurableSources; @@ -80,13 +80,7 @@ export default function CredentialBoundFields({ onBlur={onFieldBlur} > {visibleFields.map(renderField)} - {visibleAdvancedFields.length > 0 && ( - -
- {visibleAdvancedFields.map(renderField)} -
-
- )} + {visibleAdvancedFields.map(renderField)} ); } diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/DynamicConnectorCreationForm.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/DynamicConnectorCreationForm.tsx deleted file mode 100644 index c31f6bee07..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/DynamicConnectorCreationForm.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useEffect, useState } from "react"; -import CredentialSubText from "@/lib/credentials/components/CredentialFields"; -import type { ConnectionConfiguration } from "@/lib/connectors/types"; -import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; -import type { ConfigurableSources } from "@/lib/connectors/types/source"; -import type { Credential } from "@/lib/credentials/types"; -import { RenderField } from "./FieldRendering"; -import { useFormikContext } from "formik"; - -export interface DynamicConnectionFormProps { - config: ConnectionConfiguration; - values: any; - connector: ConfigurableSources; - currentCredential: Credential | null; -} - -export default function DynamicConnectionForm({ - config, - values, - connector, - currentCredential, -}: DynamicConnectionFormProps) { - const { setFieldValue } = useFormikContext(); // Get Formik's context functions - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - const [connectorNameInitialized, setConnectorNameInitialized] = - useState(false); - - let initialConnectorName = ""; - if (config.initialConnectorName) { - initialConnectorName = - currentCredential?.credential_json?.[config.initialConnectorName] ?? ""; - } - - useEffect(() => { - const field_value = values["name"]; - if (initialConnectorName && !connectorNameInitialized && !field_value) { - setFieldValue("name", initialConnectorName); - setConnectorNameInitialized(true); - } - }, [initialConnectorName, setFieldValue, values]); - - return ( - <> - {config.subtext && ( - {config.subtext} - )} - - {config.values.map( - (field) => - !field.hidden && ( - - ) - )} - - {config.advanced_values.length > 0 && - (!config.advancedValuesVisibleCondition || - config.advancedValuesVisibleCondition(values, currentCredential)) && ( - <> - - {showAdvancedOptions && - config.advanced_values.map( - (field) => - !field.hidden && ( - - ) - )} - - )} - - ); -} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/FieldRendering.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/FieldRendering.tsx index 0aa17b6cf1..1b9c045b55 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/form/FieldRendering.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/form/FieldRendering.tsx @@ -1,22 +1,33 @@ -import React, { FC, useEffect } from "react"; +import React, { FC, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; -import type { BooleanOption, TabOption } from "@/lib/connectors/types"; -import SelectInput from "./inputs/SelectInput"; -import NumberInput from "./inputs/NumberInput"; -import { TextFormField, MultiSelectField } from "@/components/Field"; -import ListInput from "./inputs/ListInput"; -import StringPairListInput from "./inputs/StringPairListInput"; -import FileInput from "./inputs/FileInput"; +import type { + BooleanOption, + TabOption, + TextSubDescriptionKey, +} from "@/lib/connectors/types"; +import { MultiSelectField } from "@/components/Field"; import type { ConfigurableSources } from "@/lib/connectors/types/source"; import type { Credential } from "@/lib/credentials/types"; import CollapsibleSection from "@/app/admin/agents/CollapsibleSection"; -import { Tabs, Text as OpalText } from "@opal/components"; +import { + InputKeyValue, + InputNumber, + Tabs, + Text as OpalText, +} from "@opal/components"; import { useField, useFormikContext } from "formik"; import * as GeneralLayouts from "@/layouts/general-layouts"; -import { Content, InputVertical } from "@opal/layouts"; -import CheckboxField from "@/refresh-components/form/LabeledCheckboxField"; +import { Content, InputHorizontal, InputVertical, Label } from "@opal/layouts"; +import { InputCheckboxField, InputSingleSelectField } from "@opal/form"; +import type { IconFunctionComponent, RichStr } from "@opal/types"; +import SwitchField from "@/refresh-components/form/SwitchField"; +import TextListField from "@/refresh-components/form/TextListField"; +import FileDropzoneField from "@/refresh-components/form/FileDropzoneField"; +import { FormikField } from "@/refresh-components/form/FormikField"; import InputTextAreaField from "@/refresh-components/form/InputTextAreaField"; -import Text from "@/refresh-components/texts/Text"; +import InputTypeInField from "@/refresh-components/form/InputTypeInField"; +import { getSourceDisplayName } from "@/lib/sources"; +import { markdown } from "@opal/utils"; // Define a general type for form values type FormValues = Record; @@ -59,11 +70,13 @@ const TabsField: FC = ({ {/* Ensure there's at least one tab before rendering */} {tabField.tabs.length === 0 ? ( - + {t("tabs.empty.label")} - + ) : ( = ({ interface CheckboxTabsFieldProps { option: BooleanOption & Required>; label: string; - description: string | undefined; disabled: boolean; } -/** A checkbox option shown as two tabs, with its label and description. */ +/** + * A checkbox option shown as two tabs. The tab labels name both choices, so + * the label only names the tab strip for assistive technology. + */ function CheckboxTabsField({ option, label, - description, disabled, }: CheckboxTabsFieldProps) { const t = useTranslations("admin.connectorsList.checkboxTabs"); @@ -146,12 +160,6 @@ function CheckboxTabsField({ // The tab strip carries strings; the form value stays a boolean. return ( - { @@ -177,6 +185,73 @@ function CheckboxTabsField({ ); } +interface CheckboxFieldProps { + name: string; + label: string | RichStr; + description: string | RichStr | undefined; + disabled: boolean; +} + +/** + * A boolean option with the checkbox in the icon slot, left of the title. + * The label around it hands a click on the title or description to the + * checkbox. + */ +function CheckboxField({ + name, + label, + description, + disabled, +}: CheckboxFieldProps) { + // A stable component identity keeps React from remounting the checkbox. + const ariaLabel: string = typeof label === "string" ? label : label.raw; + const checkboxIcon = useMemo(() => { + function CheckboxIcon() { + return ( + + ); + } + return CheckboxIcon; + }, [name, ariaLabel, disabled]); + + return ( + + ); +} + +interface ToggleFieldProps { + name: string; + label: string | RichStr; + description: string | RichStr | undefined; + disabled: boolean; +} + +/** A boolean option as a switch, with its title and description on the left. */ +function ToggleField({ name, label, description, disabled }: ToggleFieldProps) { + return ( + + + + ); +} + interface RenderFieldProps { field: any; values: any; @@ -209,6 +284,16 @@ export const RenderField: FC = ({ typeof field.initial === "function" ? field.initial(currentCredential) : (field.initial ?? ""); + const subDescriptionKey: TextSubDescriptionKey | undefined = + field.type === "text" ? field.subDescription : undefined; + // Config descriptions carry inline markdown, such as links and `code`. + const richDescription: RichStr | undefined = + typeof description === "string" && description + ? markdown(description) + : undefined; + const optionalSuffix: string | undefined = field.optional + ? t("field.optionalSuffix.label") + : undefined; // Prepopulate the field with initialValue. A field that the credential // disables takes the credential's value, also over a value entered before @@ -237,35 +322,97 @@ export const RenderField: FC = ({ const fieldContent = ( <> {field.type === "zip" || field.type === "file" ? ( - + // The field name ties the label to the file input's id and shows the + // field's Formik error under it. + + + ) : field.type === "list" ? ( - + + + ) : field.type === "string_pair_list" ? ( - + + {/* InputKeyValue edits { key, value } rows; the config names the + keys each row saves under, such as { source, target }. */} + [] | undefined> + name={field.name} + render={(formikField, helper) => ( + ({ + key: row[field.leftKey] ?? "", + value: row[field.rightKey] ?? "", + }))} + onChange={(items) => + void helper.setValue( + items.map((item) => ({ + [field.leftKey]: item.key, + [field.rightKey]: item.value, + })) + ) + } + /> + )} + /> + ) : field.type === "select" ? ( - + // The field name ties the label to the select's id and shows the + // field's Formik error under it. + + ({ + value: option.name, + title: option.name, + }) + ), + }, + ]} + /> + ) : field.type === "multiselect" ? ( = ({ onChange={(selected) => setFieldValue(field.name, selected)} /> ) : field.type === "number" ? ( - + + + name={field.name} + render={(formikField, helper, _meta, status) => ( + { + // InputNumber has no blur callback, so touch on change to + // show the field's validation error. + void helper.setTouched(true, false); + void helper.setValue(value ?? undefined); + }} + // Some sources take -1 for "no limit", such as a recursion + // depth. + min={-1} + variant={status === "error" ? "error" : "primary"} + disabled={disabled} + /> + )} + /> + ) : field.type === "checkbox" && field.tabLabels ? ( - + ) : field.type === "checkbox" && field.asCheckbox ? ( + ) : field.type === "checkbox" ? ( - - setFieldValue(field.name, checked)} - /> - + ) : field.type === "text" ? ( - field.isTextArea ? ( - + // The field name ties the label to the input's id and shows the + // field's Formik error under it. + + {field.isTextArea ? ( - - ) : ( - setFieldValue(field.name, e.target.value)} - /> - ) + ) : ( + + )} + ) : field.type === "string_tab" ? ( - - - {description} - - + + {richDescription ?? ""} + ) : ( <> {/* oxlint-disable-next-line i18n/no-raw-jsx-text -- developer diagnostic, not copy */} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/FileInput.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/inputs/FileInput.tsx deleted file mode 100644 index 4672492d81..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/FileInput.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useField } from "formik"; -import { useTranslations } from "next-intl"; -import { FileUpload } from "@/components/admin/connectors/FileUpload"; -import CredentialSubText from "@/lib/credentials/components/CredentialFields"; - -interface FileInputProps { - name: string; - label?: string; - optional?: boolean; - description?: string; - multiple?: boolean; - isZip?: boolean; - hideError?: boolean; -} - -export default function FileInput({ - name, - label, - optional = false, - description, - multiple = true, - isZip = false, // Default to false for multiple file uploads - hideError = false, -}: FileInputProps) { - const t = useTranslations("admin.connectorsList"); - const [field, meta, helpers] = useField(name); - - return ( - <> - {label && ( - - )} - {description && {description}} - { - if (isZip || !multiple) { - helpers.setValue(files[0] || null); - } else { - helpers.setValue(files); - } - }} - multiple={!isZip && multiple} // Allow multiple files if not a zip - accept={isZip ? ".zip" : undefined} // Only accept zip files if isZip is true - /> - {!hideError && meta.touched && meta.error && ( -
{meta.error}
- )} - - ); -} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/ListInput.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/inputs/ListInput.tsx deleted file mode 100644 index e633e9bf49..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/ListInput.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import { TextArrayField } from "@/components/Field"; -import { getIn, useFormikContext } from "formik"; -import { useTranslations } from "next-intl"; - -interface ListInputProps { - name: string; - label: string | ((credential: any) => string); - description: string | ((credential: any) => string); -} - -const ListInput: React.FC = ({ name, label, description }) => { - const t = useTranslations("admin.connectorsList"); - const { errors, touched, values } = useFormikContext(); - const error = getIn(errors, name); - const showArrayError = getIn(touched, name) && typeof error === "string"; - - return ( - <> - - {showArrayError && ( -
{error}
- )} - - ); -}; - -export default ListInput; diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/NumberInput.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/inputs/NumberInput.tsx deleted file mode 100644 index 4da321b04a..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/NumberInput.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Label, SubLabel } from "@/components/Field"; -import { ErrorMessage, useField } from "formik"; -import { useTranslations } from "next-intl"; - -export default function NumberInput({ - label, - optional, - description, - name, - showNeverIfZero, -}: { - label: string; - name: string; - optional?: boolean; - description?: string; - showNeverIfZero?: boolean; -}) { - const t = useTranslations("admin.connectorsList"); - const [field, meta, helpers] = useField(name); - - const handleChange = (e: React.ChangeEvent) => { - // If the input is empty, set the value to undefined or null - // This prevents the "NaN from empty string" error - if (e.target.value === "") { - helpers.setValue(undefined); - } else { - helpers.setValue(Number(e.target.value)); - } - }; - - return ( -
- - {description && {description}} - - - -
- ); -} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/SelectInput.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/inputs/SelectInput.tsx deleted file mode 100644 index da2b1f1379..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/SelectInput.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import CredentialSubText from "@/lib/credentials/components/CredentialFields"; -import type { StringWithDescription } from "@/lib/connectors/types"; -import { InputSingleSelect } from "@opal/components"; -import { useField } from "formik"; -import { useTranslations } from "next-intl"; - -export default function SelectInput({ - name, - optional, - description, - options, - label, -}: { - name: string; - optional?: boolean; - description?: string; - options: StringWithDescription[]; - label?: string; -}) { - const t = useTranslations("admin.connectorsList"); - const [field, , helpers] = useField(name); - - return ( - <> - - {description && {description}} - - { - // The empty option is a choice here, as it was in the native select. - void helpers.setValue(value); - void helpers.setTouched(true, false); - }} - placeholder={t("selectInput.emptyOption.label")} - options={[ - { - options: options.map((option) => ({ - value: option.name, - title: option.name, - })), - }, - ]} - /> - - ); -} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/StringPairListInput.tsx b/web/src/views/admin/connectors/AddConnectorPage/form/inputs/StringPairListInput.tsx deleted file mode 100644 index c2944c0c2a..0000000000 --- a/web/src/views/admin/connectors/AddConnectorPage/form/inputs/StringPairListInput.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from "react"; -import { - ArrayHelpers, - ErrorMessage, - FieldArray, - useFormikContext, -} from "formik"; -import { useTranslations } from "next-intl"; -import { Button, Spacer, Text } from "@opal/components"; -import { InputErrorText, InputVertical, Section } from "@opal/layouts"; -import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons"; -import InputTypeInField from "@/refresh-components/form/InputTypeInField"; - -interface StringPairListInputProps { - name: string; - label: string; - description?: string; - leftKey: string; - rightKey: string; - leftLabel: string; - rightLabel: string; - leftPlaceholder?: string; - rightPlaceholder?: string; -} - -// Matches the min-width of a `size="sm"` Button so the header column labels stay -// aligned with the input columns above each row's remove button. -const REMOVE_BUTTON_WIDTH_REM = 1.5; - -/** A list of labeled string pairs rendered as two side-by-side inputs per row, - * each serialized to an object keyed by leftKey/rightKey (e.g. URL rewrite - * rules: { source: prefix, target: replacement }). */ -const StringPairListInput: React.FC = ({ - name, - label, - description, - leftKey, - rightKey, - leftLabel, - rightLabel, - leftPlaceholder, - rightPlaceholder, -}) => { - const t = useTranslations("admin.connectorsList"); - const { values } = useFormikContext>(); - const pairs: Record[] = values[name] || []; - - // Stable per-row keys so removing a middle row doesn't shift native input - // state (focus/autofill) onto the row that takes its index. Index keys would; - // content-derived keys would remount the row on every keystroke. New rows are - // seeded here, and the remove handler drops the key at that index so each id - // stays with its row. - const [rowKeys, setRowKeys] = React.useState<{ - keys: number[]; - nextKey: number; - }>({ - keys: pairs.map((_, index) => index), - nextKey: pairs.length, - }); - if (rowKeys.keys.length < pairs.length) { - const keysToAdd = pairs.length - rowKeys.keys.length; - setRowKeys({ - keys: [ - ...rowKeys.keys, - ...Array.from( - { length: keysToAdd }, - (_, index) => rowKeys.nextKey + index - ), - ], - nextKey: rowKeys.nextKey + keysToAdd, - }); - } else if (rowKeys.keys.length > pairs.length) { - setRowKeys({ - keys: rowKeys.keys.slice(0, pairs.length), - nextKey: rowKeys.nextKey, - }); - } - - return ( - - ( -
- {pairs.length > 0 && ( -
-
- - {leftLabel} - -
-
- - {rightLabel} - -
- -
- )} - - {pairs.map((_, index) => ( -
-
- - -
- ( - {msg} - )} - /> -
- ))} - - -
- )} - /> -
- ); -}; - -export default StringPairListInput; diff --git a/web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorContentSection.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorContentSection.tsx new file mode 100644 index 0000000000..b4fd77676d --- /dev/null +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorContentSection.tsx @@ -0,0 +1,47 @@ +import { useTranslations } from "next-intl"; +import { Card, Collapsible } from "@opal/components"; +import { Section } from "@opal/layouts"; +import ConnectorConfigFields from "@/views/admin/connectors/AddConnectorPage/form/ConnectorConfigFields"; +import type { ConnectionConfiguration } from "@/lib/connectors/types"; +import type { ConfigurableSources } from "@/lib/connectors/types/source"; +import type { Credential } from "@/lib/credentials/types"; + +interface ConnectorContentSectionProps { + /** The connector's fields that are not bound to the credential. */ + config: ConnectionConfiguration; + values: Record; + connector: ConfigurableSources; + currentCredential: Credential | null; + /** Freezes the section while the page locks the configuration. */ + disabled?: boolean; +} + +/** What the connector indexes: the source-specific configuration fields. */ +export default function ConnectorContentSection({ + config, + values, + connector, + currentCredential, + disabled, +}: ConnectorContentSectionProps) { + const t = useTranslations("admin.connectorsList.content"); + + return ( + + +
+ +
+
+
+ ); +} diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/ConnectorSettings.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorSettingsSection.tsx similarity index 83% rename from web/src/views/admin/connectors/AddConnectorPage/form/ConnectorSettings.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorSettingsSection.tsx index 1019aa329a..7e4ca7978b 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/form/ConnectorSettings.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/ConnectorSettingsSection.tsx @@ -2,26 +2,27 @@ import { useTranslations } from "next-intl"; import { Card, Collapsible } from "@opal/components"; import { InputVertical, Section } from "@opal/layouts"; import InputTypeInField from "@/refresh-components/form/InputTypeInField"; -import DocumentAccessField from "@/lib/connectors/components/DocumentAccessField"; -import ManageAccessField from "@/lib/connectors/components/ManageAccessField"; +import DocumentAccessField from "@/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField"; +import ManageAccessField from "@/views/admin/connectors/AddConnectorPage/sections/shared/ManageAccessField"; import type { ConfigurableSources } from "@/lib/connectors/types/source"; import type { Credential } from "@/lib/credentials/types"; import { getSourceDisplayName } from "@/lib/sources"; import { useTierAtLeast } from "@/hooks/useTierAtLeast"; import { Tier } from "@/lib/settings/types"; -interface ConnectorSettingsProps { +interface ConnectorSettingsSectionProps { connector: ConfigurableSources; currentCredential: Credential | null; + /** Freezes the section while the page locks the configuration. */ disabled?: boolean; } /** The connector's name, who can read its documents, and who manages it. */ -export default function ConnectorSettings({ +export default function ConnectorSettingsSection({ connector, currentCredential, disabled, -}: ConnectorSettingsProps) { +}: ConnectorSettingsSectionProps) { const t = useTranslations("admin.connectorsList.settings"); // Below Business every connector is public and only admins manage it. const businessTier = useTierAtLeast(Tier.BUSINESS); diff --git a/web/src/views/admin/connectors/AddConnectorPage/form/Advanced.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/ScheduleSection.tsx similarity index 96% rename from web/src/views/admin/connectors/AddConnectorPage/form/Advanced.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/ScheduleSection.tsx index 1b77d5fdca..e9ad1f0b64 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/form/Advanced.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/ScheduleSection.tsx @@ -15,8 +15,9 @@ import { MIN_REFRESH_FREQ_MINUTES, } from "@/lib/connectors/connectors"; -interface AdvancedFormPageProps { +interface ScheduleSectionProps { defaultPruneFreqHours?: number; + /** Freezes the section while the page locks the configuration. */ disabled?: boolean; } @@ -37,10 +38,10 @@ function formatIndexingStart(date: Date | null): string { } /** The connector's schedule: refresh and prune frequency, and the start date. */ -export default function AdvancedFormPage({ +export default function ScheduleSection({ defaultPruneFreqHours = 600, disabled, -}: AdvancedFormPageProps) { +}: ScheduleSectionProps) { const t = useTranslations("admin.connectorsList.scheduled"); return ( diff --git a/web/src/lib/connectors/components/AutoSyncOptions.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/AutoSyncOptions.tsx similarity index 100% rename from web/src/lib/connectors/components/AutoSyncOptions.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/shared/AutoSyncOptions.tsx diff --git a/web/src/lib/connectors/components/DocumentAccessField.groups.test.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.groups.test.tsx similarity index 96% rename from web/src/lib/connectors/components/DocumentAccessField.groups.test.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.groups.test.tsx index 138ebe280e..38b5a22aba 100644 --- a/web/src/lib/connectors/components/DocumentAccessField.groups.test.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.groups.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@tests/setup/test-utils"; import userEvent from "@testing-library/user-event"; import { Formik } from "formik"; -import DocumentAccessField from "@/lib/connectors/components/DocumentAccessField"; +import DocumentAccessField from "@/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField"; import { ConfigurableSources, ValidSources, diff --git a/web/src/lib/connectors/components/DocumentAccessField.test.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.test.tsx similarity index 90% rename from web/src/lib/connectors/components/DocumentAccessField.test.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.test.tsx index 207e4e98e5..270d3b105a 100644 --- a/web/src/lib/connectors/components/DocumentAccessField.test.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@tests/setup/test-utils"; import { Formik } from "formik"; -import DocumentAccessField from "@/lib/connectors/components/DocumentAccessField"; +import DocumentAccessField from "@/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField"; import { ConfigurableSources, ValidSources, @@ -12,10 +12,13 @@ jest.mock("@/lib/permissions/hooks", () => ({ jest.mock("@/hooks/useTierAtLeast", () => ({ useTierAtLeast: jest.fn(), })); -jest.mock("@/lib/connectors/components/AutoSyncOptions", () => ({ - __esModule: true, - default: () => null, -})); +jest.mock( + "@/views/admin/connectors/AddConnectorPage/sections/shared/AutoSyncOptions", + () => ({ + __esModule: true, + default: () => null, + }) +); jest.mock("@/lib/connectors/components/GroupShareList", () => ({ __esModule: true, default: () => null, diff --git a/web/src/lib/connectors/components/DocumentAccessField.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.tsx similarity index 98% rename from web/src/lib/connectors/components/DocumentAccessField.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.tsx index a2c76846d0..2145026ba4 100644 --- a/web/src/lib/connectors/components/DocumentAccessField.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/DocumentAccessField.tsx @@ -12,7 +12,7 @@ import { ConfigurableSources, validAutoSyncSources, } from "@/lib/connectors/types/source"; -import AutoSyncOptions from "@/lib/connectors/components/AutoSyncOptions"; +import AutoSyncOptions from "@/views/admin/connectors/AddConnectorPage/sections/shared/AutoSyncOptions"; import ConnectorGroupRestrictionPicker from "@/lib/connectors/components/ConnectorGroupRestrictionPicker"; import { useConnectorGroupRestrictionsEnabled } from "@/lib/connectors/hooks"; import GroupShareList from "@/lib/connectors/components/GroupShareList"; diff --git a/web/src/lib/connectors/components/ManageAccessField.tsx b/web/src/views/admin/connectors/AddConnectorPage/sections/shared/ManageAccessField.tsx similarity index 100% rename from web/src/lib/connectors/components/ManageAccessField.tsx rename to web/src/views/admin/connectors/AddConnectorPage/sections/shared/ManageAccessField.tsx diff --git a/web/tests/e2e/onboarding/onboarding_flow.spec.ts b/web/tests/e2e/onboarding/onboarding_flow.spec.ts index a26bb95e15..daa2b124c4 100644 --- a/web/tests/e2e/onboarding/onboarding_flow.spec.ts +++ b/web/tests/e2e/onboarding/onboarding_flow.spec.ts @@ -107,8 +107,10 @@ test.describe("Onboarding Flow @exclusive", () => { const chatInput = page.locator("#onyx-chat-input"); await expect(chatInput).toBeVisible(); - // Disabled wrapper is the parent div with data-opal-disabled - const disabledWrapper = chatInput.locator(".."); + // The Disabled region that contains the input, however deep it sits. + const disabledWrapper = page + .locator("[data-opal-disabled]") + .filter({ has: chatInput }); await expect(disabledWrapper).toHaveAttribute( "data-opal-disabled", "true" @@ -195,11 +197,10 @@ test.describe("Onboarding Flow @exclusive", () => { }); const chatInput = page.locator("#onyx-chat-input"); - const chatInputParent = chatInput.locator(".."); - await expect(chatInputParent).not.toHaveAttribute( - "aria-disabled", - "true" - ); + // No disabled region contains the input. + await expect( + page.locator('[aria-disabled="true"]').filter({ has: chatInput }) + ).toHaveCount(0); }); }); @@ -251,8 +252,10 @@ test.describe("Onboarding Flow @exclusive", () => { }); const chatInput = page.locator("#onyx-chat-input"); - // Disabled wrapper is the parent div with data-opal-disabled - const disabledWrapper = chatInput.locator(".."); + // The Disabled region that contains the input, however deep it sits. + const disabledWrapper = page + .locator("[data-opal-disabled]") + .filter({ has: chatInput }); await expect(disabledWrapper).toHaveAttribute( "data-opal-disabled", "true" @@ -301,11 +304,10 @@ test.describe("Onboarding Flow @exclusive", () => { }); const chatInput = page.locator("#onyx-chat-input"); - const chatInputParent = chatInput.locator(".."); - await expect(chatInputParent).not.toHaveAttribute( - "aria-disabled", - "true" - ); + // No disabled region contains the input. + await expect( + page.locator('[aria-disabled="true"]').filter({ has: chatInput }) + ).toHaveCount(0); }); test("after setting name, shows confirmation then no onboarding UI", async ({ diff --git a/web/tests/e2e/pages/OneDriveConnectorSetupPage.ts b/web/tests/e2e/pages/OneDriveConnectorSetupPage.ts index 3a7b35db59..49248df3bf 100644 --- a/web/tests/e2e/pages/OneDriveConnectorSetupPage.ts +++ b/web/tests/e2e/pages/OneDriveConnectorSetupPage.ts @@ -165,8 +165,11 @@ export class OneDriveConnectorSetupPage { async selectSpecificScope(user?: string) { await this.page.getByRole("tab", { name: "Specific" }).click(); if (!user) return; - await this.page.getByRole("button", { name: "Add" }).click(); - await this.page.locator("#users").fill(user); + // The Excluded Paths list has its own add button outside the tab, so the + // users list is found inside the Specific tab's panel. + const panel = this.page.getByRole("tabpanel", { name: "Specific" }); + await panel.getByRole("button", { name: "Add New" }).click(); + await panel.getByRole("textbox").last().fill(user); } async selectGeneralScope() {