refactor(connectors): clean up the Connector Content section (#15617)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-10-07 00:56:52 +00:00
1 parent 7512729af3
commit 512e19bf24
45 files changed
+1288 -943

No files matched your search

@@ -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<number | null>(null);
return (
<>
<InputNumber id="n" value={value} onChange={setValue} min={min} />
<output data-testid="value">{String(value)}</output>
</>
);
}
it("takes a typed negative value when min is negative", async () => {
const user = userEvent.setup();
render(<Harness min={-1} />);
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(<Harness min={0} />);
await user.type(screen.getByRole("textbox"), "-5");
expect(screen.getByRole("textbox")).toHaveValue("5");
expect(screen.getByTestId("value")).toHaveTextContent("5");
});
@@ -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
@@ -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}
@@ -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(
<Disabled disabled>
<input aria-label="name" />
<button>Save</button>
</Disabled>
);
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(
<Disabled disabled allowClick>
<input aria-label="name" />
</Disabled>
);
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 (
<>
<button onClick={() => setLocked((value) => !value)}>toggle</button>
<Disabled disabled={locked}>
<input aria-label="name" />
</Disabled>
</>
);
}
render(<Harness />);
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(
<TooltipProvider>
<Disabled disabled tooltip={markdown("Select **an account** first")}>
<input aria-label="name" />
</Disabled>
</TooltipProvider>
);
const status = screen.getByText("Select an account first");
expect(status).toHaveAttribute("aria-live", "polite");
rerender(
<TooltipProvider>
<Disabled disabled={false} tooltip="Select an account first">
<input aria-label="name" />
</Disabled>
</TooltipProvider>
);
expect(screen.queryByText("Select an account first")).not.toBeInTheDocument();
});
+10 -1
View File
@@ -6,12 +6,21 @@ Wrapper component that applies baseline disabled CSS (opacity, cursor, pointer-e
children. Renders a `<div>` 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 `<fieldset>` (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 |
+23 -3
View File
@@ -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 `<div>` 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 = (
<div
@@ -80,7 +89,18 @@ function Disabled({
aria-disabled={disabled || undefined}
data-opal-disabled={disabled || undefined}
data-allow-click={disabled && enableClick ? "" : undefined}
/>
>
{/* 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. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{disabled && tooltip ? toPlainString(tooltip) : ""}
</span>
<fieldset disabled={blockKeyboard} className="contents">
{children}
</fieldset>
</div>
);
if (!showTooltip) return wrapper;
+2 -2
View File
@@ -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 (
<div className="w-full">
<FieldLabel name={name} label={label} subtext={subtext} />
<FileInput name={fileName} multiple={false} hideError />
<FileDropzoneField name={fileName} multiple={false} />
</div>
);
}
+22 -18
View File
@@ -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": "فتح الشريط الجانبي"
+22 -18
View File
@@ -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"
+22 -18
View File
@@ -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"
+22 -18
View File
@@ -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"
+22 -18
View File
@@ -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"
+22 -18
View File
@@ -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": "サイドバーを開く"
+22 -18
View File
@@ -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": "사이드바 열기"
+22 -18
View File
@@ -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"
+22 -18
View File
@@ -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": "打开侧边栏"
@@ -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<string, unknown>): Credential<unknown> {
return { id: 1, credential_json: json } as unknown as Credential<unknown>;
}
function renderDriveFields(currentCredential: Credential<unknown> | null) {
const values = createConnectorInitialValues(ValidSources.GoogleDrive);
render(
<Formik initialValues={values} onSubmit={() => {}}>
<ConnectorConfigFields
config={connectorConfigs.google_drive}
values={values}
connector={ValidSources.GoogleDrive}
currentCredential={currentCredential}
/>
</Formik>
);
}
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();
});
+38 -43
View File
@@ -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: [
{
@@ -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<string, string>[]) {
return {
...createConnectorInitialValues(ValidSources.Web),
name: "web",
base_url: "https://docs.example.com",
url_rewrites: urlRewrites,
};
}
function validate(urlRewrites: Record<string, string>[]) {
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");
});
+12 -1
View File
@@ -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<any> | 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<any> | 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<Option, "label"> {
type: "tab";
/** Heading above the tabs. Leave it out when the tab labels say enough. */
label?: Option["label"];
defaultTab?: string;
tabs: {
label: string;
+44 -1
View File
@@ -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"
@@ -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(<ZoomForm />);
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 () => {
@@ -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<File | File[] | null | undefined>(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 (
<div
{...getRootProps()}
className={cn(
"flex w-full items-center gap-2 rounded-xl border border-dashed p-2",
isDragActive
? "border-action-selection-05 bg-action-selection-01"
: "border-border-01"
)}
>
<input
{...getInputProps({ id: name, "aria-label": ariaLabel ?? chooseLabel })}
/>
<Button
type="button"
icon={SvgUploadCloud}
prominence="secondary"
disabled={disabled}
onClick={open}
>
{chooseLabel}
</Button>
<Text font="secondary-body" color="text-03">
{selectedFiles.length > 0
? selectedFiles.map((file) => file.name).join(", ")
: t("prompt", { multiple: String(!single) })}
</Text>
</div>
);
}
@@ -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<Record<string, unknown>>();
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 (
<FieldArray
name={name}
render={(arrayHelpers: ArrayHelpers) => (
<Section gap={2} alignItems="start" width="full">
{items.map((_, index) => (
<Section
key={rowKeys.keys[index]}
gap={1}
alignItems="start"
width="full"
>
<Section
flexDirection="row"
justifyContent="start"
alignItems="center"
gap={1}
width="full"
>
<InputTypeInField
name={`${name}.${index}`}
placeholder={placeholder}
variant={disabled ? "disabled" : undefined}
autoComplete="off"
/>
<Button
icon={SvgMinusCircle}
prominence="tertiary"
size="sm"
type="button"
disabled={disabled}
tooltip={t("removeButton.tooltip")}
aria-label={t("removeButton.tooltip")}
onClick={() => {
rowKeys.removeKey(index);
arrayHelpers.remove(index);
}}
/>
</Section>
<ErrorMessage
name={`${name}.${index}`}
render={(msg) => (
<InputErrorText type="error">{msg}</InputErrorText>
)}
/>
</Section>
))}
<Button
icon={SvgPlusCircle}
prominence="secondary"
type="button"
disabled={disabled}
onClick={() => arrayHelpers.push("")}
>
{t("addButton.label")}
</Button>
{listErrorText && (
<InputErrorText type="error">{listErrorText}</InputErrorText>
)}
</Section>
)}
/>
);
}
@@ -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<T>(
}
}
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 && (
<Divider
paddingParallel={0}
paddingPerpendicular={2}
paddingPerpendicular={0}
/>
)}
</>
@@ -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. */}
<Disabled
disabled={!configUnlocked}
tooltip={gateMessage ?? undefined}
>
<Card
border="solid"
rounding={4}
padding={6}
disabled={!configUnlocked}
>
{/* A disabled fieldset also takes the controls out of
the tab order; the wrapper above only blocks the
pointer. */}
<fieldset
disabled={!configUnlocked}
className="contents"
data-testid="connector-form"
>
<Section gap={4} alignItems="start" width="full">
{/* Announces why the configuration is locked
when the reason changes. */}
<Section
alignItems="start"
width="full"
height="fit"
aria-live="polite"
>
<Content
title={t("sections.configuration.title")}
description={gateMessage ?? undefined}
sizePreset="main-content"
variant="section"
/>
</Section>
<DynamicConnectionForm
values={formikProps.values}
config={credentialBoundFields.rest}
connector={connector}
currentCredential={formCredential}
/>
</Section>
</fieldset>
</Card>
</Disabled>
{(!noCredentials || hasVisibleBoundFields) && (
<Divider paddingParallel={0} paddingPerpendicular={0} />
)}
<Divider paddingParallel={0} paddingPerpendicular={0} />
{/* 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. */}
<Disabled
disabled={!configUnlocked}
tooltip={gateMessage ?? undefined}
data-testid="connector-form"
>
<fieldset disabled={!configUnlocked} className="contents">
<ConnectorSettings
<Section gap={6} alignItems="stretch" width="full">
<ConnectorContentSection
config={credentialBoundFields.rest}
values={formikProps.values}
connector={connector}
currentCredential={formCredential}
disabled={!configUnlocked}
/>
</fieldset>
</Disabled>
{connector !== "file" && (
<>
<Divider paddingParallel={0} paddingPerpendicular={0} />
<Disabled
<ConnectorSettingsSection
connector={connector}
currentCredential={formCredential}
disabled={!configUnlocked}
tooltip={gateMessage ?? undefined}
>
<fieldset
disabled={!configUnlocked}
className="contents"
>
<AdvancedFormPage
/>
{connector !== "file" && (
<>
<Divider
paddingParallel={0}
paddingPerpendicular={0}
/>
<ScheduleSection
defaultPruneFreqHours={defaultPruneFreqHours}
disabled={!configUnlocked}
/>
</fieldset>
</Disabled>
</>
)}
</>
)}
</Section>
</Disabled>
</Section>
</>
)}
@@ -37,7 +37,7 @@ function labelOf(
): string {
return typeof field.label === "function"
? field.label(credential)
: field.label;
: (field.label ?? field.name);
}
/**
@@ -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<any> | null;
}
export default function ConnectorConfigFields({
config,
values,
connector,
currentCredential,
}: ConnectorConfigFieldsProps) {
const { setFieldValue } = useFormikContext<any>(); // 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 && (
<CredentialSubText>{config.subtext}</CredentialSubText>
)}
{visibleFields.map((field) => (
<RenderField
key={field.name}
field={field}
values={values}
connector={connector}
currentCredential={currentCredential}
/>
))}
</>
);
}
@@ -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<string, unknown>;
connector: ConfigurableSources;
@@ -80,13 +80,7 @@ export default function CredentialBoundFields({
onBlur={onFieldBlur}
>
{visibleFields.map(renderField)}
{visibleAdvancedFields.length > 0 && (
<Collapsible defaultOpen={false} title={t("advancedOptions")}>
<Section gap={4} alignItems="start" width="full">
{visibleAdvancedFields.map(renderField)}
</Section>
</Collapsible>
)}
{visibleAdvancedFields.map(renderField)}
</Section>
);
}
@@ -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<any> | null;
}
export default function DynamicConnectionForm({
config,
values,
connector,
currentCredential,
}: DynamicConnectionFormProps) {
const { setFieldValue } = useFormikContext<any>(); // 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 && (
<CredentialSubText>{config.subtext}</CredentialSubText>
)}
{config.values.map(
(field) =>
!field.hidden && (
<RenderField
key={field.name}
field={field}
values={values}
connector={connector}
currentCredential={currentCredential}
/>
)
)}
{config.advanced_values.length > 0 &&
(!config.advancedValuesVisibleCondition ||
config.advancedValuesVisibleCondition(values, currentCredential)) && (
<>
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions}
/>
{showAdvancedOptions &&
config.advanced_values.map(
(field) =>
!field.hidden && (
<RenderField
key={field.name}
field={field}
values={values}
connector={connector}
currentCredential={currentCredential}
/>
)
)}
</>
)}
</>
);
}
@@ -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<string, any>;
@@ -59,11 +70,13 @@ const TabsField: FC<TabsFieldProps> = ({
{/* Ensure there's at least one tab before rendering */}
{tabField.tabs.length === 0 ? (
<Text text03 secondaryBody>
<OpalText font="secondary-body" color="text-03">
{t("tabs.empty.label")}
</Text>
</OpalText>
) : (
<Tabs
// Space between the tab strip and the fields of the open tab.
gap={4}
value={
values[tabField.name] ??
tabField.defaultTab ??
@@ -128,15 +141,16 @@ const TabsField: FC<TabsFieldProps> = ({
interface CheckboxTabsFieldProps {
option: BooleanOption & Required<Pick<BooleanOption, "tabLabels">>;
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 (
<GeneralLayouts.Section gap={1} alignItems="start">
<Content
title={label}
description={description}
sizePreset="main-content"
variant="section"
/>
<Tabs
value={String(value ?? option.default ?? false)}
onValueChange={(next) => {
@@ -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<IconFunctionComponent>(() => {
function CheckboxIcon() {
return (
<InputCheckboxField
name={name}
aria-label={ariaLabel}
disabled={disabled}
/>
);
}
return CheckboxIcon;
}, [name, ariaLabel, disabled]);
return (
<Label disabled={disabled}>
<Content
icon={checkboxIcon}
title={label}
description={description}
sizePreset="main-ui"
variant="section"
/>
</Label>
);
}
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 (
<InputHorizontal
withLabel
disabled={disabled}
title={label}
description={description}
>
<SwitchField name={name} disabled={disabled} />
</InputHorizontal>
);
}
interface RenderFieldProps {
field: any;
values: any;
@@ -209,6 +284,16 @@ export const RenderField: FC<RenderFieldProps> = ({
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<RenderFieldProps> = ({
const fieldContent = (
<>
{field.type === "zip" || field.type === "file" ? (
<FileInput
name={field.name}
isZip={field.type === "zip"}
label={label}
optional={field.optional}
description={description}
/>
// The field name ties the label to the file input's id and shows the
// field's Formik error under it.
<InputVertical
withLabel={field.name}
disabled={disabled}
title={label}
subDescription={richDescription}
suffix={optionalSuffix}
>
<FileDropzoneField
name={field.name}
isZip={field.type === "zip"}
aria-label={label}
disabled={disabled}
/>
</InputVertical>
) : field.type === "list" ? (
<ListInput name={field.name} label={label} description={description} />
<InputVertical
disabled={disabled}
title={label}
subDescription={richDescription}
suffix={optionalSuffix}
>
<TextListField
name={field.name}
placeholder={t("listInput.placeholder", {
label: label.toLowerCase(),
})}
disabled={disabled}
/>
</InputVertical>
) : field.type === "string_pair_list" ? (
<StringPairListInput
name={field.name}
label={label}
description={description}
leftKey={field.leftKey}
rightKey={field.rightKey}
leftLabel={field.leftLabel}
rightLabel={field.rightLabel}
leftPlaceholder={field.leftPlaceholder}
rightPlaceholder={field.rightPlaceholder}
/>
<InputVertical
disabled={disabled}
title={label}
subDescription={richDescription}
suffix={optionalSuffix}
>
{/* InputKeyValue edits { key, value } rows; the config names the
keys each row saves under, such as { source, target }. */}
<FormikField<Record<string, string>[] | undefined>
name={field.name}
render={(formikField, helper) => (
<InputKeyValue
keyTitle={field.leftLabel}
valueTitle={field.rightLabel}
keyPlaceholder={field.leftPlaceholder}
valuePlaceholder={field.rightPlaceholder}
items={(formikField.value ?? []).map((row) => ({
key: row[field.leftKey] ?? "",
value: row[field.rightKey] ?? "",
}))}
onChange={(items) =>
void helper.setValue(
items.map((item) => ({
[field.leftKey]: item.key,
[field.rightKey]: item.value,
}))
)
}
/>
)}
/>
</InputVertical>
) : field.type === "select" ? (
<SelectInput
name={field.name}
optional={field.optional}
description={description}
options={field.options || []}
label={label}
/>
// The field name ties the label to the select's id and shows the
// field's Formik error under it.
<InputVertical
withLabel={field.name}
disabled={disabled}
title={label}
subDescription={richDescription}
suffix={optionalSuffix}
>
<InputSingleSelectField
name={field.name}
id={field.name}
placeholder={t("selectInput.emptyOption.label")}
disabled={disabled}
options={[
{
options: (field.options ?? []).map(
(option: { name: string }) => ({
value: option.name,
title: option.name,
})
),
},
]}
/>
</InputVertical>
) : field.type === "multiselect" ? (
<MultiSelectField
name={field.name}
@@ -281,69 +428,87 @@ export const RenderField: FC<RenderFieldProps> = ({
onChange={(selected) => setFieldValue(field.name, selected)}
/>
) : field.type === "number" ? (
<NumberInput
label={label}
optional={field.optional}
description={description}
name={field.name}
/>
<InputVertical
withLabel={field.name}
disabled={disabled}
title={label}
subDescription={richDescription}
suffix={optionalSuffix}
>
<FormikField<number | undefined>
name={field.name}
render={(formikField, helper, _meta, status) => (
<InputNumber
id={field.name}
value={formikField.value ?? null}
onChange={(value) => {
// 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}
/>
)}
/>
</InputVertical>
) : field.type === "checkbox" && field.tabLabels ? (
<CheckboxTabsField
option={field}
<CheckboxTabsField option={field} label={label} disabled={disabled} />
) : field.type === "checkbox" && field.asCheckbox ? (
<CheckboxField
name={field.name}
label={label}
description={description}
description={richDescription}
disabled={disabled}
/>
) : field.type === "checkbox" ? (
<GeneralLayouts.Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={2}
>
<CheckboxField
name={field.name}
label={label}
sublabel={description}
disabled={disabled}
onChange={(checked) => setFieldValue(field.name, checked)}
/>
</GeneralLayouts.Section>
<ToggleField
name={field.name}
label={label}
description={richDescription}
disabled={disabled}
/>
) : field.type === "text" ? (
field.isTextArea ? (
<InputVertical
withLabel={field.name}
title={label}
description={description}
suffix={
field.optional ? t("field.optionalSuffix.label") : undefined
}
>
// The field name ties the label to the input's id and shows the
// field's Formik error under it.
<InputVertical
withLabel={field.name}
title={label}
subDescription={
subDescriptionKey
? t(`subDescriptions.${subDescriptionKey}`, {
connectorName: getSourceDisplayName(connector) ?? connector,
})
: richDescription
}
suffix={optionalSuffix}
>
{field.isTextArea ? (
<InputTextAreaField
name={field.name}
// Tests find a config text field by its config name.
data-testid={field.name}
placeholder={field.placeholder}
variant={disabled ? "disabled" : undefined}
rows={1}
/>
</InputVertical>
) : (
<TextFormField
subtext={description}
optional={field.optional}
type={field.type}
label={label}
name={field.name}
isTextArea={false}
disabled={disabled}
onChange={(e) => setFieldValue(field.name, e.target.value)}
/>
)
) : (
<InputTypeInField
name={field.name}
data-testid={field.name}
placeholder={field.placeholder}
variant={disabled ? "disabled" : undefined}
/>
)}
</InputVertical>
) : field.type === "string_tab" ? (
<GeneralLayouts.Section>
<Text text03 secondaryBody>
{description}
</Text>
</GeneralLayouts.Section>
<OpalText font="secondary-body" color="text-03">
{richDescription ?? ""}
</OpalText>
) : (
<>
{/* oxlint-disable-next-line i18n/no-raw-jsx-text -- developer diagnostic, not copy */}
@@ -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 && (
<label
htmlFor={name}
className="block text-sm font-medium text-text-700 mb-1"
>
{label}
{optional && (
<span className="text-text-500 ms-1">
{t("field.optional.label")}
</span>
)}
</label>
)}
{description && <CredentialSubText>{description}</CredentialSubText>}
<FileUpload
selectedFiles={
Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
}
setSelectedFiles={(files: File[]) => {
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 && (
<div className="text-red-500 text-sm mt-1">{meta.error}</div>
)}
</>
);
}
@@ -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<ListInputProps> = ({ name, label, description }) => {
const t = useTranslations("admin.connectorsList");
const { errors, touched, values } = useFormikContext<any>();
const error = getIn(errors, name);
const showArrayError = getIn(touched, name) && typeof error === "string";
return (
<>
<TextArrayField
name={name}
label={typeof label === "function" ? label(null) : label}
values={values}
subtext={
typeof description === "function" ? description(null) : description
}
placeholder={t("listInput.placeholder", {
label:
typeof label === "function" ? label(null) : label.toLowerCase(),
})}
/>
{showArrayError && (
<div className="text-action-danger-05 text-sm mt-1">{error}</div>
)}
</>
);
};
export default ListInput;
@@ -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<HTMLInputElement>) => {
// 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 (
<div className="w-full flex flex-col">
<Label>
<>
{label}
{optional && (
<span className="text-text-500 ms-1">
{t("field.optional.label")}
</span>
)}
</>
</Label>
{description && <SubLabel>{description}</SubLabel>}
<input
{...field}
type="number"
min="-1"
onChange={handleChange}
value={
field.value === undefined || field.value === null ? "" : field.value
}
className={`mt-2 block w-full px-3 py-2
bg-white dark:bg-transparent border border-background-300 rounded-md
text-sm shadow-xs placeholder-text-400
focus:outline-hidden focus:border-sky-500 focus:ring-1 focus:ring-sky-500
disabled:bg-background-50 disabled:text-text-500 disabled:border-background-200 disabled:shadow-none
invalid:border-pink-500 invalid:text-pink-600
focus:invalid:border-pink-500 focus:invalid:ring-pink-500`}
/>
<ErrorMessage
name={name}
component="div"
className="text-error text-sm mt-1"
/>
</div>
);
}
@@ -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<string>(name);
return (
<>
<label
htmlFor={name}
className="block text-sm font-medium text-text-700 mb-1"
>
{label}
{optional && (
<span className="text-text-500 ms-1">
{t("field.optional.label")}
</span>
)}
</label>
{description && <CredentialSubText>{description}</CredentialSubText>}
<InputSingleSelect
id={name}
value={field.value ?? ""}
onValueChange={(value) => {
// 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,
})),
},
]}
/>
</>
);
}
@@ -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<StringPairListInputProps> = ({
name,
label,
description,
leftKey,
rightKey,
leftLabel,
rightLabel,
leftPlaceholder,
rightPlaceholder,
}) => {
const t = useTranslations("admin.connectorsList");
const { values } = useFormikContext<Record<string, any>>();
const pairs: Record<string, string>[] = 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 (
<InputVertical title={label} description={description}>
<FieldArray
name={name}
render={(arrayHelpers: ArrayHelpers) => (
<Section gap={2} alignItems="start" width="full">
{pairs.length > 0 && (
<Section
flexDirection="row"
justifyContent="start"
alignItems="center"
gap={1}
width="full"
>
<Section width="full" alignItems="start" gap={0}>
<Text font="secondary-body" color="text-03">
{leftLabel}
</Text>
</Section>
<Section width="full" alignItems="start" gap={0}>
<Text font="secondary-body" color="text-03">
{rightLabel}
</Text>
</Section>
<Spacer
orientation="horizontal"
rem={REMOVE_BUTTON_WIDTH_REM}
/>
</Section>
)}
{pairs.map((_, index) => (
<Section
key={rowKeys.keys[index]}
gap={1}
alignItems="start"
width="full"
>
<Section
flexDirection="row"
justifyContent="start"
alignItems="center"
gap={1}
width="full"
>
<InputTypeInField
name={`${name}.${index}.${leftKey}`}
placeholder={leftPlaceholder}
autoComplete="off"
/>
<InputTypeInField
name={`${name}.${index}.${rightKey}`}
placeholder={rightPlaceholder}
autoComplete="off"
/>
<Button
icon={SvgMinusCircle}
prominence="tertiary"
size="sm"
type="button"
tooltip={t("stringPairList.removeButton.tooltip")}
onClick={() => {
setRowKeys((prev) => ({
keys: prev.keys.filter((_, i) => i !== index),
nextKey: prev.nextKey,
}));
arrayHelpers.remove(index);
}}
/>
</Section>
<ErrorMessage
name={`${name}.${index}`}
render={(msg) => (
<InputErrorText type="error">{msg}</InputErrorText>
)}
/>
</Section>
))}
<Button
icon={SvgPlusCircle}
prominence="secondary"
type="button"
onClick={() => {
arrayHelpers.push({ [leftKey]: "", [rightKey]: "" });
}}
>
{t("stringPairList.addButton.label")}
</Button>
</Section>
)}
/>
</InputVertical>
);
};
export default StringPairListInput;
@@ -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<string, unknown>;
connector: ConfigurableSources;
currentCredential: Credential<any> | 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 (
<Collapsible
title={t("title")}
description={t("description")}
disabled={disabled}
>
<Card border="solid" rounding={4} padding={4} disabled={disabled}>
<Section gap={4} alignItems="start" width="full">
<ConnectorConfigFields
values={values}
config={config}
connector={connector}
currentCredential={currentCredential}
/>
</Section>
</Card>
</Collapsible>
);
}
@@ -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<any> | 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);
@@ -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 (
@@ -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,
@@ -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,
@@ -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";
@@ -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 ({
@@ -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() {