mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
refactor(connectors): clean up the Connector Content section (#15617)
This commit is contained in:
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();
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": "فتح الشريط الجانبي"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "サイドバーを開く"
|
||||
|
||||
@@ -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": "사이드바 열기"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
-5
@@ -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);
|
||||
+4
-3
@@ -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 (
|
||||
File renamed without changes.
+1
-1
@@ -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,
|
||||
+8
-5
@@ -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,
|
||||
+1
-1
@@ -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";
|
||||
File renamed without changes.
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user