mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(opal): update InputUpload and remove previous, legacy component (#15965)
This commit is contained in:
1 parent
8ce6fab7c5
commit
48605418eb
46 files changed
+1169
-637
No files matched your search
@@ -8,7 +8,7 @@
|
||||
# `ods type-coverage typescript --update`.
|
||||
#
|
||||
# Generated file: do not edit by hand.
|
||||
total: 98.8
|
||||
total: 98.9
|
||||
packages:
|
||||
.: 100
|
||||
src: 99.4
|
||||
@@ -21,15 +21,15 @@ packages:
|
||||
src/app/components: 100
|
||||
src/app/config: 100
|
||||
src/app/connector: 100
|
||||
src/app/craft: 99.3
|
||||
src/app/craft: 99.4
|
||||
src/app/ee: 95.6
|
||||
src/app/federated: 97
|
||||
src/app/mcp: 99.5
|
||||
src/app/nrf: 99.2
|
||||
src/app/oauth-config: 100
|
||||
src/app/oauth-provider: 100
|
||||
src/components: 97.7
|
||||
src/components/admin: 96.1
|
||||
src/components: 97.6
|
||||
src/components/admin: 95.8
|
||||
src/components/auth: 89.3
|
||||
src/components/chat: 97.2
|
||||
src/components/context: 100
|
||||
@@ -42,7 +42,7 @@ packages:
|
||||
src/components/oauth: 89.5
|
||||
src/components/search: 99.5
|
||||
src/components/standardAnswers: 100
|
||||
src/components/tools: 99.7
|
||||
src/components/tools: 100
|
||||
src/components/tooltip: 100
|
||||
src/components/ui: 99.9
|
||||
src/components/voice: 100
|
||||
@@ -69,7 +69,7 @@ packages:
|
||||
src/lib/chat: 98.6
|
||||
src/lib/connectors: 99
|
||||
src/lib/constants: 100
|
||||
src/lib/credentials: 98.3
|
||||
src/lib/credentials: 98.4
|
||||
src/lib/extension: 100
|
||||
src/lib/externalApps: 100
|
||||
src/lib/hierarchy: 100
|
||||
@@ -91,10 +91,11 @@ packages:
|
||||
src/lib/sidebar: 100
|
||||
src/lib/skills: 97.4
|
||||
src/lib/sso: 99.3
|
||||
src/lib/tiers: 100
|
||||
src/lib/tools: 98.1
|
||||
src/lib/tracing: 96.9
|
||||
src/lib/usage: 97.9
|
||||
src/lib/users: 99
|
||||
src/lib/users: 99.1
|
||||
src/lib/voice: 99.3
|
||||
src/lib/webSearch: 97.4
|
||||
src/providers: 99.5
|
||||
@@ -130,5 +131,5 @@ packages:
|
||||
src/sections/skills: 99.8
|
||||
src/sections/usage: 99.6
|
||||
src/views: 99.4
|
||||
src/views/admin: 98.4
|
||||
src/views/admin: 98.5
|
||||
types: 100
|
||||
@@ -22,7 +22,7 @@ The barrel file at `index.ts` re-exports each component and its prop types. Each
|
||||
| [OpenButton](./buttons/open-button/) | Trigger button with rotating chevron for popovers | [README](./buttons/open-button/README.md) |
|
||||
| [Tag](./tag/) | Small colored label for status/category metadata | [README](./tag/README.md) |
|
||||
| [InputDateRangePicker](./inputs/chrono/input-date-range-picker/) | Preset date-range strip with a custom-range calendar popover | [README](./inputs/chrono/input-date-range-picker/README.md) |
|
||||
| [InputFile](./inputs/input-file/) | Text field that doubles as a file picker | [README](./inputs/input-file/README.md) |
|
||||
| [InputUpload](./inputs/input-upload/) | Upload control in the input chrome: pick, drop or paste | [README](./inputs/input-upload/README.md) |
|
||||
| [InputAvatar](./inputs/input-avatar/) | Avatar frame with primary input chrome | [README](./inputs/input-avatar/README.md) |
|
||||
| [InputImage](./inputs/input-image/) | Circular image dropzone with edit overlay | [README](./inputs/input-image/README.md) |
|
||||
| [InputKeyValue](./inputs/input-key-value/) | Key/value pair editor with validation | [README](./inputs/input-key-value/README.md) |
|
||||
|
||||
@@ -91,11 +91,13 @@ export {
|
||||
type InputNumberProps,
|
||||
} from "@opal/components/inputs/input-number/components";
|
||||
|
||||
/* InputFile */
|
||||
/* InputUpload */
|
||||
export {
|
||||
default as InputFile,
|
||||
type InputFileProps,
|
||||
} from "@opal/components/inputs/input-file/components";
|
||||
default as InputUpload,
|
||||
type InputUploadProps,
|
||||
type InputUploadTextProps,
|
||||
type InputUploadFileProps,
|
||||
} from "@opal/components/inputs/input-upload/components";
|
||||
|
||||
/* InputList */
|
||||
export {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# InputFile
|
||||
|
||||
**Import:** `import { InputFile, type InputFileProps } from "@opal/components";`
|
||||
|
||||
A text field that doubles as a file picker: type a value directly, or attach a file via the
|
||||
paperclip action and the field displays the file's name and carries its text content. Clearing
|
||||
returns to typed mode. Built on `InputTypeIn`; the attach/clear actions are `Button`s in the
|
||||
right slot.
|
||||
|
||||
## Props
|
||||
|
||||
Extends `InputTypeInProps` minus the props the component owns itself —
|
||||
`type`, `rightChildren`, `value`, `onChange`, `readOnly`, `clearButton` —
|
||||
plus:
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------------- | ------------------------------- | ------------------------------------------------------ |
|
||||
| `setValue` | `(value: string) => void` | Receives the typed text or the selected file's content |
|
||||
| `onValueSet` | callback | Fires after a value lands |
|
||||
| `accept` | `string` | File-picker accept filter |
|
||||
| `maxSizeKb` | `number` | Reject files above this size |
|
||||
| `onFileSizeExceeded` | `({ file, maxSizeKb }) => void` | Called when a file is rejected for size |
|
||||
|
||||
The attach/clear aria labels come from `OpalStrings` (`attachFile` / `clearFile`).
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import InputTypeIn, {
|
||||
type InputTypeInProps,
|
||||
} from "@opal/components/inputs/texts/input-type-in/components";
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import { SvgPaperclip, SvgX } from "@opal/icons";
|
||||
import { noProp } from "@opal/utils";
|
||||
import { useOpalStrings } from "@opal/strings";
|
||||
|
||||
export interface InputFileProps extends Omit<
|
||||
InputTypeInProps,
|
||||
"type" | "rightChildren" | "value" | "onChange" | "readOnly" | "clearButton"
|
||||
> {
|
||||
/**
|
||||
* Whether the input is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Whether the input has an error.
|
||||
*/
|
||||
error?: boolean;
|
||||
// Receives the extracted file content (text) or pasted value
|
||||
setValue: (value: string) => void;
|
||||
// Called when a value is committed via file selection or paste (not on each keystroke)
|
||||
onValueSet?: (value: string, source: "file" | "paste") => void;
|
||||
// HTML accept attribute e.g. "application/json" or ".txt,.md"
|
||||
accept?: string;
|
||||
// Maximum allowed file size in kilobytes. If exceeded, file is rejected.
|
||||
maxSizeKb?: number;
|
||||
// Optional callback when the selected file exceeds max size
|
||||
onFileSizeExceeded?: (args: { file: File; maxSizeKb: number }) => void;
|
||||
}
|
||||
|
||||
export default function InputFile({
|
||||
setValue,
|
||||
onValueSet,
|
||||
accept,
|
||||
maxSizeKb,
|
||||
onFileSizeExceeded,
|
||||
disabled,
|
||||
error,
|
||||
variant,
|
||||
placeholder,
|
||||
...rest
|
||||
}: InputFileProps) {
|
||||
const strings = useOpalStrings();
|
||||
const [displayValue, setDisplayValue] = useState<string>("");
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [isFileMode, setIsFileMode] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Derive disabled/readOnly state from either the boolean props or the variant
|
||||
const isDisabled = disabled || variant === "disabled";
|
||||
const isReadOnly = variant === "readOnly";
|
||||
const isNonEditable = isDisabled || isReadOnly;
|
||||
|
||||
function openFilePicker() {
|
||||
if (isNonEditable) return;
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Enforce file size limit if provided
|
||||
if (typeof maxSizeKb === "number" && maxSizeKb >= 0) {
|
||||
const maxBytes = maxSizeKb * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
onFileSizeExceeded?.({ file, maxSizeKb });
|
||||
// Reset file input to allow re-selecting the same file
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const textContent =
|
||||
typeof reader.result === "string" ? reader.result : "";
|
||||
setValue(textContent);
|
||||
setSelectedFileName(file.name);
|
||||
setDisplayValue(file.name);
|
||||
setIsFileMode(true);
|
||||
onValueSet?.(textContent, "file");
|
||||
};
|
||||
reader.onerror = () => {
|
||||
// Reset state on error
|
||||
setSelectedFileName(null);
|
||||
setDisplayValue("");
|
||||
setIsFileMode(false);
|
||||
setValue("");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// clear the input value to allow re-selecting the same file if needed
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setSelectedFileName(null);
|
||||
setDisplayValue("");
|
||||
setIsFileMode(false);
|
||||
setValue("");
|
||||
}
|
||||
|
||||
function handleChangeWhenTyping(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (isFileMode) return; // ignore typing when file-mode is active
|
||||
const next = e.target.value;
|
||||
setDisplayValue(next);
|
||||
setValue(next);
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
// Don't allow paste when non-editable
|
||||
if (isNonEditable) return;
|
||||
// Switch to editable mode and use pasted text as the value
|
||||
const pastedText = e.clipboardData.getData("text");
|
||||
if (!pastedText) return;
|
||||
e.preventDefault();
|
||||
setIsFileMode(false);
|
||||
setSelectedFileName(null);
|
||||
setDisplayValue(pastedText);
|
||||
setValue(pastedText);
|
||||
onValueSet?.(pastedText, "paste");
|
||||
}
|
||||
|
||||
const rightChildren = isFileMode ? (
|
||||
<Button
|
||||
icon={SvgX}
|
||||
onClick={noProp(handleClear)}
|
||||
type="button"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={strings.clearFile}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={isNonEditable}
|
||||
icon={SvgPaperclip}
|
||||
onClick={noProp(openFilePicker)}
|
||||
type="button"
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
aria-label={strings.attachFile}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
aria-hidden
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
disabled={isNonEditable}
|
||||
/>
|
||||
<InputTypeIn
|
||||
{...rest}
|
||||
placeholder={placeholder}
|
||||
variant={
|
||||
isDisabled
|
||||
? "disabled"
|
||||
: error
|
||||
? "error"
|
||||
: isFileMode || isReadOnly
|
||||
? "readOnly"
|
||||
: variant
|
||||
}
|
||||
value={displayValue}
|
||||
onChange={handleChangeWhenTyping}
|
||||
onPaste={handlePaste}
|
||||
rightChildren={rightChildren}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+36
-10
@@ -1,11 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import InputFile from "@opal/components/inputs/input-file/components";
|
||||
import InputUpload from "@opal/components/inputs/input-upload/components";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta: Meta<typeof InputFile> = {
|
||||
title: "opal/components/InputFile",
|
||||
component: InputFile,
|
||||
const meta: Meta<typeof InputUpload> = {
|
||||
title: "opal/components/InputUpload",
|
||||
component: InputUpload,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
@@ -19,13 +19,16 @@ const meta: Meta<typeof InputFile> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputFile>;
|
||||
type Story = StoryObj<typeof InputUpload>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: function DefaultStory() {
|
||||
const [, setValue] = React.useState("");
|
||||
return (
|
||||
<InputFile placeholder="Paste or attach a file..." setValue={setValue} />
|
||||
<InputUpload
|
||||
placeholder="Paste or attach a file..."
|
||||
setValue={setValue}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -34,7 +37,7 @@ export const WithAcceptFilter: Story = {
|
||||
render: function AcceptFilterStory() {
|
||||
const [, setValue] = React.useState("");
|
||||
return (
|
||||
<InputFile
|
||||
<InputUpload
|
||||
placeholder="JSON files only..."
|
||||
setValue={setValue}
|
||||
accept="application/json,.json"
|
||||
@@ -47,7 +50,7 @@ export const WithMaxSize: Story = {
|
||||
render: function MaxSizeStory() {
|
||||
const [, setValue] = React.useState("");
|
||||
return (
|
||||
<InputFile
|
||||
<InputUpload
|
||||
placeholder="Max 100KB..."
|
||||
setValue={setValue}
|
||||
maxSizeKb={100}
|
||||
@@ -61,7 +64,11 @@ export const WithMaxSize: Story = {
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<InputFile placeholder="Cannot upload" setValue={() => {}} disabled />
|
||||
<InputUpload
|
||||
placeholder="Cannot upload"
|
||||
setValue={() => {}}
|
||||
variant="disabled"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -69,7 +76,26 @@ export const ErrorState: Story = {
|
||||
render: function ErrorStory() {
|
||||
const [, setValue] = React.useState("");
|
||||
return (
|
||||
<InputFile placeholder="Required file..." setValue={setValue} error />
|
||||
<InputUpload
|
||||
placeholder="Required file..."
|
||||
setValue={setValue}
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FileMode: Story = {
|
||||
render: function FileModeStory() {
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
return (
|
||||
<InputUpload
|
||||
mode="file"
|
||||
accept=".pfx"
|
||||
placeholder="Attach a .pfx certificate"
|
||||
value={file}
|
||||
onFileChange={setFile}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
# InputUpload
|
||||
|
||||
**Import:** `import { InputUpload, type InputUploadProps } from "@opal/components";`
|
||||
**Formik:** `import { InputUploadField } from "@opal/form";`
|
||||
|
||||
An upload control in the input chrome, for one file in a form row. Nothing is typed: a click, Enter
|
||||
or Space opens the file picker, and a file can be dropped on it. With a value, Delete or Backspace
|
||||
clears it, as the × does; clearing with the × returns focus to the control. It leads with an import icon, shows
|
||||
the placeholder until a file is in, then the file's name, with a × at the end that clears it.
|
||||
|
||||
While files are dragged anywhere over the page (`useWindowFileDrag` from `@opal/hooks`), the control
|
||||
shows itself as a drop target: a dashed `action-selection-05` border, `action-selection-01` fill, and
|
||||
blue icon and text. Over the control itself the fill deepens to `action-selection-02`. The × hides
|
||||
during a drag. The copy says what a drop does:
|
||||
|
||||
| Drag | Empty | Holding a file |
|
||||
| --------------- | ----------------------- | --------------------------- |
|
||||
| Over the page | "Drop file here to add" | the file's name |
|
||||
| Over the control | "Drop file here to add" | "Drop here to replace file" |
|
||||
|
||||
A dropped file must match `accept`, and is refused above `maxSizeKb`, as a picked one is.
|
||||
|
||||
## Modes
|
||||
|
||||
- **`mode="text"`** (default): the value is the file's **text** (`setValue`), for text files such as a
|
||||
JSON key. Focused, the control also takes a paste (Cmd/Ctrl+V); the pasted text becomes the value
|
||||
and the control shows "Pasted text".
|
||||
- **`mode="file"`**: the value is the **`File`** itself, unread (`onFileChange`), for a binary file
|
||||
such as a `.pfx` certificate. No paste.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Mode | Type | Description |
|
||||
| -------------------- | ---- | ------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `mode` | both | `"text" \| "file"` | Default `"text"` |
|
||||
| `value` | text | `string` | The value, when the caller owns it. Set from outside, it shows "File attached" |
|
||||
| `setValue` | text | `(value: string) => void` | Receives the file's text or the pasted text; `""` once cleared |
|
||||
| `value` | file | `File \| null` | The file, when the caller owns it |
|
||||
| `onFileChange` | file | `(file: File \| null) => void` | Receives the file, or `null` once cleared |
|
||||
| `variant` | both | `InputVariants` | `"primary"` (default), `"error"`, `"disabled"` (no picking, dropping or pasting), `"readOnly"` |
|
||||
| `placeholder` | both | `string \| RichStr` | Shown while empty. Default: "Add a file…" |
|
||||
| `accept` | both | `string` | The picker's filter, as the HTML `accept` attribute; a drop must match it |
|
||||
| `maxSizeKb` | both | `number` | Rejects a file larger than this |
|
||||
| `onFileSizeExceeded` | both | `({ file, maxSizeKb }) => void` | Called when a file is rejected for its size |
|
||||
| `id`, `name`, `onBlur`, `data-testid` | both | — | Passed to the control (`name` to the hidden file input) |
|
||||
|
||||
Without `value`, the control keeps its own. `InputUploadField` binds it to Formik: a `string` field in
|
||||
text mode, a `File | null` field in file mode. An upload, paste or clear marks the field touched; a
|
||||
touched field with an error shows the error variant.
|
||||
|
||||
Labels come from `OpalStrings`: `inputUploadPlaceholder`, `clearFile`, `inputUploadPasted`, `inputUploadAttached`,
|
||||
`inputUploadDropToAdd` and `inputUploadDropToReplace`.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
<InputUpload accept="application/json" placeholder="Upload a key file" setValue={setKey} />
|
||||
|
||||
<InputUpload mode="file" accept=".pfx" onFileChange={setCertificate} />
|
||||
|
||||
<InputUploadField name="apiKey" accept=".json" />
|
||||
```
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import "@opal/components/inputs/shared.css";
|
||||
import "@opal/components/inputs/input-upload/styles.css";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Button } from "@opal/components/buttons/button/components";
|
||||
import { Text } from "@opal/components/text/components";
|
||||
import { SvgImport, SvgX } from "@opal/icons";
|
||||
import { noProp } from "@opal/utils";
|
||||
import { useOpalStrings } from "@opal/strings";
|
||||
import useWindowFileDrag from "@opal/hooks/useWindowFileDrag";
|
||||
import type { InputVariants, RichStr } from "@opal/types";
|
||||
|
||||
interface InputUploadBaseProps {
|
||||
/** Visual state, as on the other inputs. @default "primary" */
|
||||
variant?: InputVariants;
|
||||
/**
|
||||
* Shown while nothing is uploaded. Accepts `markdown()`. Default: "Add a
|
||||
* file…" (`OpalStrings.inputUploadPlaceholder`).
|
||||
*/
|
||||
placeholder?: string | RichStr;
|
||||
/** The control's id, so a `<label htmlFor>` names it and opens it. */
|
||||
id?: string;
|
||||
/** Submitted with the hidden file input. */
|
||||
name?: string;
|
||||
/** Called when focus leaves the control. */
|
||||
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
"data-testid"?: string;
|
||||
/**
|
||||
* The file picker's filter, as the HTML `accept` attribute
|
||||
* (`"application/json,.json"`). A dropped file must match it too.
|
||||
*/
|
||||
accept?: string;
|
||||
/** Rejects a file larger than this many kilobytes. */
|
||||
maxSizeKb?: number;
|
||||
/** Called when a file is rejected for its size. */
|
||||
onFileSizeExceeded?: (args: { file: File; maxSizeKb: number }) => void;
|
||||
}
|
||||
|
||||
/** The default: the uploaded file's text, or pasted text. */
|
||||
export type InputUploadTextProps = InputUploadBaseProps & {
|
||||
mode?: "text";
|
||||
/**
|
||||
* The value, when the caller owns it. Unset, the control keeps its own.
|
||||
* A change from outside (a reset) drops the uploaded file's name.
|
||||
*/
|
||||
value?: string;
|
||||
/** Receives the uploaded file's text or the pasted text; `""` once cleared. */
|
||||
setValue: (value: string) => void;
|
||||
};
|
||||
|
||||
/** The uploaded file itself, for a binary file such as a certificate. */
|
||||
export type InputUploadFileProps = InputUploadBaseProps & {
|
||||
mode: "file";
|
||||
/** The file, when the caller owns it. Unset, the control keeps its own. */
|
||||
value?: File | null;
|
||||
/** Receives the uploaded file, or `null` once it is cleared. */
|
||||
onFileChange: (file: File | null) => void;
|
||||
};
|
||||
|
||||
export type InputUploadProps = InputUploadTextProps | InputUploadFileProps;
|
||||
|
||||
/** Whether `file` matches an HTML `accept` list: extensions, MIME types or `type/*`. */
|
||||
function matchesAccept(file: File, accept: string | undefined): boolean {
|
||||
if (!accept) return true;
|
||||
const name = file.name.toLowerCase();
|
||||
const type = file.type.toLowerCase();
|
||||
return accept
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.some((entry) =>
|
||||
entry.startsWith(".")
|
||||
? name.endsWith(entry)
|
||||
: entry.endsWith("/*")
|
||||
? type.startsWith(entry.slice(0, -1))
|
||||
: type === entry
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upload control in the input chrome. A click (or Enter or Space) opens
|
||||
* the file picker, and a file can be dropped on it. Nothing is typed. Once a
|
||||
* file is in, it shows the file's name, and the × at the end clears it.
|
||||
*
|
||||
* While files are dragged anywhere over the page, it shows itself as a drop
|
||||
* target, darker once the pointer is over it. Its copy says what a drop
|
||||
* does: add a file, or replace the one it holds.
|
||||
*
|
||||
* - `mode="text"` (default): the value is the file's text. Focused, the
|
||||
* control also takes a paste, whose text becomes the value.
|
||||
* - `mode="file"`: the value is the `File` itself, unread, for a binary file
|
||||
* such as a certificate.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <InputUpload accept="application/json" setValue={setKey} />
|
||||
*
|
||||
* <InputUpload mode="file" accept=".pfx" onFileChange={setCertificate} />
|
||||
* ```
|
||||
*/
|
||||
export default function InputUpload(props: InputUploadProps) {
|
||||
const {
|
||||
variant = "primary",
|
||||
placeholder,
|
||||
id,
|
||||
name,
|
||||
onBlur,
|
||||
accept,
|
||||
maxSizeKb,
|
||||
onFileSizeExceeded,
|
||||
} = props;
|
||||
const strings = useOpalStrings();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const isNonEditable = variant === "disabled" || variant === "readOnly";
|
||||
|
||||
// Text mode: the value when the caller does not own it, and what it came
|
||||
// from: a file's name, or a paste.
|
||||
const [ownText, setOwnText] = useState<string>("");
|
||||
const [source, setSource] = useState<
|
||||
{ kind: "file"; name: string } | { kind: "paste" } | null
|
||||
>(null);
|
||||
// File mode: the file, when the caller does not own it.
|
||||
const [ownFile, setOwnFile] = useState<File | null>(null);
|
||||
|
||||
// The last value this control reported. A different one from outside (a
|
||||
// form reset) forgets where the old value came from.
|
||||
const reportedRef = useRef<string | undefined>(undefined);
|
||||
const outsideText = props.mode !== "file" ? props.value : undefined;
|
||||
useEffect(() => {
|
||||
if (outsideText !== undefined && outsideText !== reportedRef.current) {
|
||||
setSource(null);
|
||||
}
|
||||
}, [outsideText]);
|
||||
|
||||
const currentFile: File | null =
|
||||
props.mode === "file"
|
||||
? props.value !== undefined
|
||||
? props.value
|
||||
: ownFile
|
||||
: null;
|
||||
const currentText: string =
|
||||
props.mode === "file" ? "" : (props.value ?? ownText);
|
||||
|
||||
// What the control shows: the file's name, "Pasted text", or, for a value
|
||||
// set from outside, "File attached".
|
||||
const label: string | null =
|
||||
props.mode === "file"
|
||||
? (currentFile?.name ?? null)
|
||||
: source?.kind === "file"
|
||||
? source.name
|
||||
: source?.kind === "paste"
|
||||
? strings.inputUploadPasted
|
||||
: currentText
|
||||
? strings.inputUploadAttached
|
||||
: null;
|
||||
const hasValue: boolean = label !== null;
|
||||
|
||||
function reportText(text: string) {
|
||||
if (props.mode === "file") return;
|
||||
reportedRef.current = text;
|
||||
if (props.value === undefined) setOwnText(text);
|
||||
props.setValue(text);
|
||||
}
|
||||
|
||||
function reportFile(file: File | null) {
|
||||
if (props.mode !== "file") return;
|
||||
if (props.value === undefined) setOwnFile(file);
|
||||
props.onFileChange(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setSource(null);
|
||||
if (props.mode === "file") reportFile(null);
|
||||
else reportText("");
|
||||
}
|
||||
|
||||
/** Takes a picked or dropped file, unless its type or size is refused. */
|
||||
function takeFile(file: File) {
|
||||
if (!matchesAccept(file, accept)) return;
|
||||
if (typeof maxSizeKb === "number" && maxSizeKb >= 0) {
|
||||
if (file.size > maxSizeKb * 1024) {
|
||||
onFileSizeExceeded?.({ file, maxSizeKb });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (props.mode === "file") {
|
||||
reportFile(file);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setSource({ kind: "file", name: file.name });
|
||||
reportText(typeof reader.result === "string" ? reader.result : "");
|
||||
};
|
||||
reader.onerror = clear;
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// Drag and drop only: a click opens the picker below.
|
||||
const { getRootProps, isDragActive } = useDropzone({
|
||||
onDrop: (files) => {
|
||||
const file = files[0];
|
||||
if (file) takeFile(file);
|
||||
},
|
||||
multiple: false,
|
||||
disabled: isNonEditable,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
// While files are dragged: anywhere over the page, or over this control.
|
||||
const filesDragged = useWindowFileDrag(!isNonEditable);
|
||||
const drag: "page" | "over" | null = isDragActive
|
||||
? "over"
|
||||
: filesDragged
|
||||
? "page"
|
||||
: null;
|
||||
// Over the page, a held file keeps its name; over the control, the copy
|
||||
// says the drop replaces it.
|
||||
const shownLabel: string | null =
|
||||
drag === null
|
||||
? label
|
||||
: !hasValue
|
||||
? strings.inputUploadDropToAdd
|
||||
: drag === "over"
|
||||
? strings.inputUploadDropToReplace
|
||||
: label;
|
||||
|
||||
function openFilePicker() {
|
||||
if (!isNonEditable) fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) takeFile(file);
|
||||
// Lets the same file be picked again.
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLButtonElement>) {
|
||||
if (props.mode === "file" || isNonEditable) return;
|
||||
const pastedText = e.clipboardData.getData("text");
|
||||
if (!pastedText) return;
|
||||
e.preventDefault();
|
||||
setSource({ kind: "paste" });
|
||||
reportText(pastedText);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps({ className: "opal-input-upload" })}
|
||||
data-drag={drag ?? undefined}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
name={name}
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
aria-hidden
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
disabled={isNonEditable}
|
||||
/>
|
||||
<div className="opal-input" data-variant={variant}>
|
||||
{/* The whole field picks a file, as a button a label can point at;
|
||||
the × beside it is its own button. Its name is the label, else its
|
||||
text. Enter and Space open the picker, as on any button. */}
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
disabled={isNonEditable}
|
||||
data-testid={props["data-testid"]}
|
||||
className="opal-input-upload-trigger"
|
||||
onClick={openFilePicker}
|
||||
ref={triggerRef}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
// Delete or Backspace clears the value, as the × does.
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (!hasValue) return;
|
||||
e.preventDefault();
|
||||
clear();
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className="opal-input-leading-icon">
|
||||
<SvgImport className="opal-input-leading-icon-svg" />
|
||||
</div>
|
||||
<span
|
||||
className="opal-input-upload-label"
|
||||
data-placeholder={shownLabel === null ? true : undefined}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
font={
|
||||
drag !== null
|
||||
? "main-ui-action"
|
||||
: shownLabel === null
|
||||
? "main-ui-muted"
|
||||
: "main-ui-body"
|
||||
}
|
||||
color="inherit"
|
||||
maxLines={1}
|
||||
>
|
||||
{shownLabel ?? placeholder ?? strings.inputUploadPlaceholder}
|
||||
</Text>
|
||||
</span>
|
||||
</button>
|
||||
{hasValue && !isNonEditable && drag === null && (
|
||||
<Button
|
||||
icon={SvgX}
|
||||
onClick={noProp(() => {
|
||||
clear();
|
||||
// The × goes with the value, so focus returns to the control.
|
||||
triggerRef.current?.focus();
|
||||
})}
|
||||
type="button"
|
||||
prominence="internal"
|
||||
size="sm"
|
||||
aria-label={strings.clearFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@reference "#reference.css";
|
||||
|
||||
/* The drop target: the control and its hidden file input. */
|
||||
.opal-input-upload {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* The field itself picks a file: a bare button with no caret, a pointer, and
|
||||
the row's text one line tall, as a typed input's is. */
|
||||
.opal-input-upload-trigger {
|
||||
@apply flex flex-1 min-w-0 items-center h-6 cursor-pointer select-none outline-hidden bg-transparent border-0 p-0 text-start;
|
||||
}
|
||||
|
||||
.opal-input[data-variant="disabled"] .opal-input-upload-trigger {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.opal-input[data-variant="readOnly"] .opal-input-upload-trigger {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
/* The uploaded file's name, or the placeholder in its muted colour; the
|
||||
font is the Text's own. */
|
||||
.opal-input-upload-label {
|
||||
@apply flex min-w-0 py-0.5 text-text-04;
|
||||
}
|
||||
|
||||
.opal-input-upload-label[data-placeholder] {
|
||||
@apply text-text-02;
|
||||
}
|
||||
|
||||
.opal-input[data-variant="disabled"] .opal-input-upload-label {
|
||||
@apply text-text-02;
|
||||
}
|
||||
|
||||
/* While files are dragged over the page, the control shows itself as a drop
|
||||
target; over the control itself, its fill deepens. */
|
||||
.opal-input-upload[data-drag] .opal-input {
|
||||
background-color: var(--action-selection-01);
|
||||
border: 1px dashed var(--action-selection-05);
|
||||
}
|
||||
|
||||
.opal-input-upload[data-drag="over"] .opal-input {
|
||||
background-color: var(--action-selection-02);
|
||||
}
|
||||
|
||||
.opal-input-upload[data-drag] .opal-input-upload-label {
|
||||
color: var(--action-selection-05);
|
||||
}
|
||||
|
||||
.opal-input-upload[data-drag] .opal-input-leading-icon-svg {
|
||||
color: var(--action-selection-05);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "@tests/setup/test-utils";
|
||||
import "@testing-library/jest-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Formik, type FormikProps } from "formik";
|
||||
import { InputUploadField } from "@opal/form/InputUploadField";
|
||||
|
||||
interface KeyForm {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface CertificateForm {
|
||||
certificate: File | null;
|
||||
}
|
||||
|
||||
function picker(container: HTMLElement): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
if (!input) throw new Error("no file input");
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("InputUploadField", () => {
|
||||
test("text mode: a paste into the focused control becomes the value", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<KeyForm> | null = null;
|
||||
render(
|
||||
<Formik<KeyForm> initialValues={{ key: "" }} onSubmit={() => {}}>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="key" placeholder="Upload a key" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Upload a key" }));
|
||||
await user.paste("eyJsaWNlbnNl");
|
||||
|
||||
await waitFor(() => expect(form?.values.key).toBe("eyJsaWNlbnNl"));
|
||||
expect(screen.getByText("Pasted text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Delete clears an upload from the keyboard, and the × returns focus", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<KeyForm> | null = null;
|
||||
const { container } = render(
|
||||
<Formik<KeyForm> initialValues={{ key: "" }} onSubmit={() => {}}>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="key" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
".opal-input-upload-trigger"
|
||||
);
|
||||
if (!trigger) throw new Error("no trigger");
|
||||
|
||||
await user.upload(picker(container), new File(["one"], "one.txt"));
|
||||
await waitFor(() => expect(form?.values.key).toBe("one"));
|
||||
trigger.focus();
|
||||
await user.keyboard("{Delete}");
|
||||
await waitFor(() => expect(form?.values.key).toBe(""));
|
||||
|
||||
await user.upload(picker(container), new File(["two"], "two.txt"));
|
||||
await waitFor(() => expect(form?.values.key).toBe("two"));
|
||||
await user.click(screen.getByRole("button", { name: "Clear file" }));
|
||||
await waitFor(() => expect(form?.values.key).toBe(""));
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
test("the × clears an upload", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<KeyForm> | null = null;
|
||||
const { container } = render(
|
||||
<Formik<KeyForm> initialValues={{ key: "" }} onSubmit={() => {}}>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="key" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
await user.upload(picker(container), new File(["secret"], "key.txt"));
|
||||
await waitFor(() => expect(form?.values.key).toBe("secret"));
|
||||
await user.click(screen.getByRole("button", { name: "Clear file" }));
|
||||
|
||||
await waitFor(() => expect(form?.values.key).toBe(""));
|
||||
expect(screen.queryByText("key.txt")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("text mode: an attached file's text becomes the value, and its name shows", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<KeyForm> | null = null;
|
||||
const { container } = render(
|
||||
<Formik<KeyForm> initialValues={{ key: "" }} onSubmit={() => {}}>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="key" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
picker(container),
|
||||
new File(['{"a":1}'], "key.json", { type: "application/json" })
|
||||
);
|
||||
|
||||
await waitFor(() => expect(form?.values.key).toBe('{"a":1}'));
|
||||
expect(screen.getByText("key.json")).toBeInTheDocument();
|
||||
expect(form?.touched.key).toBe(true);
|
||||
});
|
||||
|
||||
test("text mode: a form reset clears the field", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<KeyForm> | null = null;
|
||||
const { container } = render(
|
||||
<Formik<KeyForm> initialValues={{ key: "" }} onSubmit={() => {}}>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="key" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
await user.upload(picker(container), new File(["secret"], "key.txt"));
|
||||
await waitFor(() => expect(form?.values.key).toBe("secret"));
|
||||
form?.resetForm();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("key.txt")).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("file mode: the field holds the File itself", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
let form: FormikProps<CertificateForm> | null = null;
|
||||
const certificate = new File([new Uint8Array([0, 255, 1])], "app.pfx");
|
||||
const { container } = render(
|
||||
<Formik<CertificateForm>
|
||||
initialValues={{ certificate: null }}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{(formik) => {
|
||||
form = formik;
|
||||
return <InputUploadField name="certificate" mode="file" />;
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
await user.upload(picker(container), certificate);
|
||||
|
||||
await waitFor(() => expect(form?.values.certificate).toBe(certificate));
|
||||
expect(screen.getByText("app.pfx")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a touched field with an error shows the error variant", () => {
|
||||
const { container } = render(
|
||||
<Formik<KeyForm>
|
||||
initialValues={{ key: "" }}
|
||||
initialTouched={{ key: true }}
|
||||
initialErrors={{ key: "Required" }}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
<InputUploadField name="key" />
|
||||
</Formik>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".opal-input")).toHaveAttribute(
|
||||
"data-variant",
|
||||
"error"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useField } from "formik";
|
||||
import InputUpload, {
|
||||
type InputUploadFileProps,
|
||||
type InputUploadTextProps,
|
||||
} from "@opal/components/inputs/input-upload/components";
|
||||
import type { DistributiveOmit } from "@opal/types";
|
||||
|
||||
/** Every `InputUpload` prop except the value and its setter, which Formik owns. */
|
||||
type InputUploadFieldProps = (
|
||||
| DistributiveOmit<InputUploadTextProps, "value" | "setValue">
|
||||
| DistributiveOmit<InputUploadFileProps, "value" | "onFileChange">
|
||||
) & {
|
||||
/** The Formik field name. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formik-bound `InputUpload`. Text mode holds a `string` field (the uploaded
|
||||
* file's text, or pasted text); `mode="file"` holds a `File | null` field.
|
||||
* An upload, a paste or a clear marks the field touched, as leaving it does;
|
||||
* a touched field with an error shows the error variant unless `variant` is
|
||||
* set.
|
||||
*/
|
||||
function InputUploadField({
|
||||
name,
|
||||
variant,
|
||||
onBlur,
|
||||
...props
|
||||
}: InputUploadFieldProps) {
|
||||
const [field, meta, helpers] = useField<unknown>(name);
|
||||
const isError: boolean = !!meta.touched && meta.error !== undefined;
|
||||
const shared = {
|
||||
id: props.id ?? name,
|
||||
name,
|
||||
variant: variant ?? (isError ? "error" : "primary"),
|
||||
onBlur: (event: React.FocusEvent<HTMLButtonElement>) => {
|
||||
onBlur?.(event);
|
||||
void helpers.setTouched(true);
|
||||
},
|
||||
} as const;
|
||||
|
||||
// setValue validates with the new value; setTouched must not validate again
|
||||
// with the pre-change values.
|
||||
if (props.mode === "file") {
|
||||
return (
|
||||
<InputUpload
|
||||
{...props}
|
||||
{...shared}
|
||||
value={field.value instanceof File ? field.value : null}
|
||||
onFileChange={(file: File | null) => {
|
||||
void helpers.setValue(file);
|
||||
void helpers.setTouched(true, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InputUpload
|
||||
{...props}
|
||||
{...shared}
|
||||
value={typeof field.value === "string" ? field.value : ""}
|
||||
setValue={(value: string) => {
|
||||
void helpers.setValue(value);
|
||||
void helpers.setTouched(true, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputUploadField, type InputUploadFieldProps };
|
||||
@@ -9,6 +9,10 @@ export {
|
||||
InputCheckboxField,
|
||||
type InputCheckboxFieldProps,
|
||||
} from "@opal/form/InputCheckboxField";
|
||||
export {
|
||||
InputUploadField,
|
||||
type InputUploadFieldProps,
|
||||
} from "@opal/form/InputUploadField";
|
||||
export {
|
||||
InputSingleSelectField,
|
||||
type InputSingleSelectFieldProps,
|
||||
|
||||
@@ -37,3 +37,4 @@ export {
|
||||
type UseImageDropzoneReturn,
|
||||
} from "@opal/hooks/useImageDropzone";
|
||||
export { default as usePresence } from "@opal/hooks/usePresence";
|
||||
export { default as useWindowFileDrag } from "@opal/hooks/useWindowFileDrag";
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Whether a drag carries files (not text or a link dragged in the page). */
|
||||
function carriesFiles(event: DragEvent): boolean {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
||||
/**
|
||||
* True while files are dragged anywhere over the window, so a drop target can
|
||||
* show itself before the pointer reaches it. Ends on a drop, a cancelled drag
|
||||
* or the pointer leaving the window.
|
||||
*
|
||||
* const filesDragged = useWindowFileDrag(!disabled);
|
||||
*/
|
||||
export default function useWindowFileDrag(enabled: boolean = true): boolean {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setDragging(false);
|
||||
return;
|
||||
}
|
||||
// dragenter and dragleave fire for every element crossed, so a count of
|
||||
// the open enters says whether the drag is still inside the window.
|
||||
let depth = 0;
|
||||
const onEnter = (event: DragEvent) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
depth += 1;
|
||||
setDragging(true);
|
||||
};
|
||||
const onLeave = (event: DragEvent) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) setDragging(false);
|
||||
};
|
||||
const onEnd = () => {
|
||||
depth = 0;
|
||||
setDragging(false);
|
||||
};
|
||||
window.addEventListener("dragenter", onEnter);
|
||||
window.addEventListener("dragleave", onLeave);
|
||||
window.addEventListener("drop", onEnd);
|
||||
window.addEventListener("dragend", onEnd);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", onEnter);
|
||||
window.removeEventListener("dragleave", onLeave);
|
||||
window.removeEventListener("drop", onEnd);
|
||||
window.removeEventListener("dragend", onEnd);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return dragging;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgImport = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6.75 9.41667L9.41667 6.75M9.41667 6.75L6.75 4.08333M9.41667 6.75L0.75 6.74667M2.75 3.75V2.08C2.75 1.34546 3.34546 0.75 4.08 0.75H11.4167C11.7703 0.75 12.1094 0.890476 12.3595 1.14052C12.6095 1.39057 12.75 1.72971 12.75 2.08333V11.4167C12.75 11.7703 12.6095 12.1094 12.3595 12.3595C12.1094 12.6095 11.7703 12.75 11.4167 12.75H4.08C3.34546 12.75 2.75 12.1545 2.75 11.42V9.75"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgImport;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgImport = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.5 5.33333L10.1667 8L7.5 10.6667M10.1667 8L1.5 7.99667M3.5 5V3.33C3.5 2.59546 4.09546 2 4.83 2H12.1667C12.8929 2 13.5 2.60711 13.5 3.33333V12.6667C13.5 13.3929 12.8929 14 12.1667 14H4.83C4.09546 14 3.5 13.4045 3.5 12.67V11"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgImport;
|
||||
@@ -108,7 +108,7 @@ export { default as SvgShareWebhook } from "@opal/icons/share-webhook";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
export { default as SvgImport } from "@opal/icons/import-icon";
|
||||
export { default as SvgImport } from "@opal/icons/import";
|
||||
export { default as SvgInfo } from "@opal/icons/info";
|
||||
export { default as SvgInfoSmall } from "@opal/icons/info-small";
|
||||
export { default as SvgKey } from "@opal/icons/key";
|
||||
|
||||
@@ -15,8 +15,17 @@ export type OpalStrings = {
|
||||
copied: string;
|
||||
copyCode: string;
|
||||
clear: string;
|
||||
attachFile: string;
|
||||
clearFile: string;
|
||||
/** InputUpload's placeholder when its caller gives none. */
|
||||
inputUploadPlaceholder: string;
|
||||
/** InputUpload's label for a value that was pasted, not uploaded. */
|
||||
inputUploadPasted: string;
|
||||
/** InputUpload's label for a value set from outside, such as a saved one. */
|
||||
inputUploadAttached: string;
|
||||
/** InputUpload's copy while a file is dragged and it holds none. */
|
||||
inputUploadDropToAdd: string;
|
||||
/** InputUpload's copy while a file is dragged over it and it holds one. */
|
||||
inputUploadDropToReplace: string;
|
||||
clearFilter: string;
|
||||
date: string;
|
||||
dateRange: string;
|
||||
@@ -121,8 +130,12 @@ export const defaultOpalStrings: OpalStrings = {
|
||||
copied: "Copied!",
|
||||
copyCode: "Copy code",
|
||||
clear: "Clear",
|
||||
attachFile: "Attach file",
|
||||
clearFile: "Clear file",
|
||||
inputUploadPlaceholder: "Add a file…",
|
||||
inputUploadPasted: "Pasted text",
|
||||
inputUploadAttached: "File attached",
|
||||
inputUploadDropToAdd: "Drop file here to add",
|
||||
inputUploadDropToReplace: "Drop here to replace file",
|
||||
clearFilter: "Clear filter",
|
||||
date: "Date",
|
||||
dateRange: "Date range",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { InputFile } from "@opal/components";
|
||||
import { InputUpload } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { InputVertical } from "@opal/layouts";
|
||||
import { SvgXCircle, SvgCheckCircle, SvgXOctagon } from "@opal/icons";
|
||||
@@ -201,13 +201,13 @@ export default function LicenseActivationCard({
|
||||
}
|
||||
withLabel
|
||||
>
|
||||
<InputFile
|
||||
<InputUpload
|
||||
placeholder="eyJwYXlsb2FkIjogeyJ2ZXJzaW9..."
|
||||
setValue={(value) => {
|
||||
setLicenseKey(value);
|
||||
setError(null);
|
||||
}}
|
||||
error={!!error}
|
||||
variant={error ? "error" : "primary"}
|
||||
/>
|
||||
{error && (
|
||||
<Section
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useField,
|
||||
useFormikContext,
|
||||
} from "formik";
|
||||
import { FileUpload } from "@/components/admin/connectors/FileUpload";
|
||||
import * as Yup from "yup";
|
||||
import { FormBodyBuilder } from "./admin/connectors/types";
|
||||
import { InputSingleSelect, type TooltipSide } from "@opal/components";
|
||||
@@ -17,7 +16,7 @@ import { FiInfo, FiX } from "react-icons/fi";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { FaMarkdown } from "react-icons/fa";
|
||||
import { useState, useEffect, memo, JSX } from "react";
|
||||
import { useState, memo, JSX } from "react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
Button,
|
||||
@@ -31,11 +30,7 @@ import {
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { transformLinkUri } from "@/lib/utils";
|
||||
import { cn } from "@opal/utils";
|
||||
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";
|
||||
import { getCredentialFileType } from "@/lib/credentials/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
import { SvgEye, SvgEyeClosed, SvgPlusCircle } from "@opal/icons";
|
||||
@@ -393,164 +388,6 @@ export function TextFormField({
|
||||
);
|
||||
}
|
||||
|
||||
export function FileUploadFormField({
|
||||
name,
|
||||
label,
|
||||
subtext,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
subtext?: string | JSX.Element;
|
||||
}) {
|
||||
// We create a *temporary* field inside of `Formik` to throw the `File` object into.
|
||||
// The actual *contents* of the file will be thrown into the field called `name`.
|
||||
const fileName = `temporary.filename-${name}`;
|
||||
const [fileField] = useField<File>(fileName);
|
||||
const [, , contentsHelper] = useField<string>(name);
|
||||
|
||||
useEffect(() => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
contentsHelper.setValue(e.target?.result as string);
|
||||
};
|
||||
if (fileField.value instanceof File) {
|
||||
reader.readAsText(fileField.value);
|
||||
}
|
||||
}, [contentsHelper, fileField.value]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<FieldLabel name={name} label={label} subtext={subtext} />
|
||||
<FileDropzoneField name={fileName} multiple={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypedFileUploadFormField({
|
||||
name,
|
||||
label,
|
||||
subtext,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
subtext?: string | JSX.Element;
|
||||
}) {
|
||||
const t = useTranslations("common.field");
|
||||
const [field, , helpers] = useField<TypedFile | null>(name);
|
||||
const [customError, setCustomError] = useState<string>("");
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [description, setDescription] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const typeDefinitionKey = getCredentialFileType(name);
|
||||
if (typeDefinitionKey) {
|
||||
setDescription(
|
||||
FILE_TYPE_DEFINITIONS[typeDefinitionKey].description || ""
|
||||
);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
const validateFile = async () => {
|
||||
if (!field.value) {
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const validation = await field.value.validate();
|
||||
if (validation?.isValid) {
|
||||
setCustomError("");
|
||||
} else {
|
||||
setCustomError(
|
||||
validation?.errors.join(", ") || t("fileValidation.unknownError")
|
||||
);
|
||||
helpers.setValue(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setCustomError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileValidation.validationError")
|
||||
);
|
||||
helpers.setValue(null);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateFile();
|
||||
}, [field.value, helpers, t]);
|
||||
|
||||
const handleFileSelection = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
helpers.setValue(null);
|
||||
setCustomError("");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (!file) {
|
||||
setCustomError(t("fileValidation.selectionError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const typeDefinitionKey = getCredentialFileType(name);
|
||||
|
||||
if (!typeDefinitionKey) {
|
||||
setCustomError(t("fileValidation.noTypeDefinition", { name }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const typedFile = createTypedFile(file, name, typeDefinitionKey);
|
||||
helpers.setValue(typedFile);
|
||||
setCustomError("");
|
||||
} catch (error) {
|
||||
setCustomError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileValidation.unknownError")
|
||||
);
|
||||
helpers.setValue(null);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<FieldLabel name={name} label={label} subtext={subtext} />
|
||||
{description && (
|
||||
<div className="text-sm text-text-03 mb-2">{description}</div>
|
||||
)}
|
||||
<FileUpload
|
||||
selectedFiles={field.value ? [field.value.file] : []}
|
||||
setSelectedFiles={handleFileSelection}
|
||||
multiple={false}
|
||||
/>
|
||||
{/* Validation feedback */}
|
||||
{isValidating && (
|
||||
<div className="text-status-info-05 text-sm mt-1">
|
||||
{t("fileValidation.validating")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customError ? (
|
||||
<div className="text-action-danger-05 text-sm mt-1">{customError}</div>
|
||||
) : (
|
||||
<ErrorMessage
|
||||
name={name}
|
||||
component="div"
|
||||
className="text-action-danger-05 text-sm mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectField({
|
||||
name,
|
||||
label,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useFormikContext } from "formik";
|
||||
import { FC, useState } from "react";
|
||||
import React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface FileUploadProps {
|
||||
selectedFiles: File[];
|
||||
setSelectedFiles: (files: File[]) => void;
|
||||
message?: string;
|
||||
name?: string;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
export const FileUpload: FC<FileUploadProps> = ({
|
||||
name,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
message,
|
||||
multiple = true,
|
||||
accept,
|
||||
}) => {
|
||||
const t = useTranslations("admin.connector.fileUpload");
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropzone
|
||||
onDrop={(acceptedFiles) => {
|
||||
let filesToSet: File[] = [];
|
||||
if (multiple) {
|
||||
filesToSet = acceptedFiles;
|
||||
} else {
|
||||
const acceptedFile = acceptedFiles[0];
|
||||
if (acceptedFile !== undefined) {
|
||||
filesToSet = [acceptedFile];
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToSet !== undefined) {
|
||||
setSelectedFiles(filesToSet);
|
||||
}
|
||||
|
||||
setDragActive(false);
|
||||
if (name) {
|
||||
setFieldValue(name, multiple ? filesToSet : filesToSet[0]);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDragEnter={() => setDragActive(true)}
|
||||
multiple={multiple}
|
||||
accept={accept ? { [accept]: [] } : undefined}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<section>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={
|
||||
"flex flex-col items-center w-full px-4 py-12 rounded-sm " +
|
||||
"shadow-lg tracking-wide border border-border cursor-pointer" +
|
||||
(dragActive ? " border-accent" : "")
|
||||
}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<b className="text-text-darker">
|
||||
{message ||
|
||||
t("dropzone.message", {
|
||||
multiple: multiple ? "true" : "false",
|
||||
})}
|
||||
</b>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-sm font-bold">
|
||||
{t("selectedFiles.heading", {
|
||||
multiple: multiple ? "true" : "false",
|
||||
})}
|
||||
</h2>
|
||||
<ul>
|
||||
{selectedFiles.map((file) => (
|
||||
<div key={file.name} className="flex">
|
||||
<p className="text-sm me-2">{file.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,8 +27,12 @@ export default function OpalStringsBridge({
|
||||
copied: t("common.copied"),
|
||||
copyCode: t("common.copyCode"),
|
||||
clear: t("common.clear"),
|
||||
attachFile: t("inputFile.attachFile"),
|
||||
clearFile: t("inputFile.clearFile"),
|
||||
inputUploadPlaceholder: t("inputUpload.placeholder"),
|
||||
inputUploadPasted: t("inputUpload.pasted"),
|
||||
inputUploadAttached: t("inputUpload.attached"),
|
||||
inputUploadDropToAdd: t("inputUpload.dropToAdd"),
|
||||
inputUploadDropToReplace: t("inputUpload.dropToReplace"),
|
||||
clearFilter: t("common.clearFilter"),
|
||||
date: t("common.date"),
|
||||
dateRange: t("dateRange.label"),
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "مطلوب لـ Jira Cloud.",
|
||||
"lumappsServiceUser": "المستخدم الذي تتم الفهرسة نيابةً عنه.",
|
||||
"outlineBaseUrl": "مثل https://app.getoutline.com، أو عنوان URL الخاص بخادمك المستضاف ذاتيًا.",
|
||||
"pfxFile": "ملف PFX.",
|
||||
"pfxFile": "ارفع ملف `.pfx` الذي يحتوي على المفتاح الخاص لتسجيل التطبيق. يجب أن يكون أصغر من 10 كيلوبايت.",
|
||||
"testrailBaseUrl": "مثل https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "السنة"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "إرفاق ملف",
|
||||
"clearFile": "مسح الملف"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "أضف ملفًا…",
|
||||
"pasted": "نص ملصق",
|
||||
"attached": "تم إرفاق ملف",
|
||||
"dropToAdd": "أفلت الملف هنا لإضافته",
|
||||
"dropToReplace": "أفلت هنا لاستبدال الملف"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "صورة",
|
||||
"dropRejected": "تم رفض الملف",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Erforderlich für Jira Cloud.",
|
||||
"lumappsServiceUser": "Der Benutzer, in dessen Namen indiziert wird.",
|
||||
"outlineBaseUrl": "Zum Beispiel https://app.getoutline.com oder deine selbst gehostete URL.",
|
||||
"pfxFile": "Eine PFX-Datei.",
|
||||
"pfxFile": "Lade die `.pfx`-Datei mit dem privaten Schlüssel der App-Registrierung hoch. Muss kleiner als 10 KB sein.",
|
||||
"testrailBaseUrl": "Zum Beispiel https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "Jahr"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "Datei anhängen",
|
||||
"clearFile": "Datei entfernen"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "Datei hinzufügen…",
|
||||
"pasted": "Eingefügter Text",
|
||||
"attached": "Datei angehängt",
|
||||
"dropToAdd": "Datei hier ablegen, um sie hinzuzufügen",
|
||||
"dropToReplace": "Hier ablegen, um die Datei zu ersetzen"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "Bild",
|
||||
"dropRejected": "Datei abgelehnt",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Required for Jira Cloud.",
|
||||
"lumappsServiceUser": "The user to index on behalf of.",
|
||||
"outlineBaseUrl": "For example https://app.getoutline.com, or your self-hosted URL.",
|
||||
"pfxFile": "A PFX file.",
|
||||
"pfxFile": "Upload the `.pfx` file containing the private key of the app registration. Must be under 10 KB.",
|
||||
"testrailBaseUrl": "For example https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "Year"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "Attach file",
|
||||
"clearFile": "Clear file"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "Add a file…",
|
||||
"pasted": "Pasted text",
|
||||
"attached": "File attached",
|
||||
"dropToAdd": "Drop file here to add",
|
||||
"dropToReplace": "Drop here to replace file"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "Image",
|
||||
"dropRejected": "File rejected",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Obligatorio para Jira Cloud.",
|
||||
"lumappsServiceUser": "El usuario en cuyo nombre se indexa.",
|
||||
"outlineBaseUrl": "Por ejemplo, https://app.getoutline.com, o tu URL autoalojada.",
|
||||
"pfxFile": "Un archivo PFX.",
|
||||
"pfxFile": "Sube el archivo `.pfx` que contiene la clave privada del registro de la aplicación. Debe ocupar menos de 10 KB.",
|
||||
"testrailBaseUrl": "Por ejemplo, https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "Año"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "Adjuntar archivo",
|
||||
"clearFile": "Quitar archivo"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "Añadir un archivo…",
|
||||
"pasted": "Texto pegado",
|
||||
"attached": "Archivo adjunto",
|
||||
"dropToAdd": "Suelta el archivo aquí para añadirlo",
|
||||
"dropToReplace": "Suelta aquí para reemplazar el archivo"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "Imagen",
|
||||
"dropRejected": "Archivo rechazado",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Obligatoire pour Jira Cloud.",
|
||||
"lumappsServiceUser": "L'utilisateur au nom duquel indexer.",
|
||||
"outlineBaseUrl": "Par exemple https://app.getoutline.com, ou l'URL de votre instance auto-hébergée.",
|
||||
"pfxFile": "Un fichier PFX.",
|
||||
"pfxFile": "Importez le fichier `.pfx` contenant la clé privée de l’inscription d’application. Doit faire moins de 10 Ko.",
|
||||
"testrailBaseUrl": "Par exemple https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "Année"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "Joindre un fichier",
|
||||
"clearFile": "Effacer le fichier"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "Ajouter un fichier…",
|
||||
"pasted": "Texte collé",
|
||||
"attached": "Fichier joint",
|
||||
"dropToAdd": "Déposez le fichier ici pour l’ajouter",
|
||||
"dropToReplace": "Déposez ici pour remplacer le fichier"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "Image",
|
||||
"dropRejected": "Fichier refusé",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Jira Cloud では必須です。",
|
||||
"lumappsServiceUser": "代理でインデックスを作成するユーザーです。",
|
||||
"outlineBaseUrl": "例: https://app.getoutline.com、またはセルフホストの URL。",
|
||||
"pfxFile": "PFX ファイルです。",
|
||||
"pfxFile": "アプリ登録の秘密キーを含む `.pfx` ファイルをアップロードしてください。10 KB 未満である必要があります。",
|
||||
"testrailBaseUrl": "例: https://yourcompany.testrail.io。"
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "年"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "ファイルを添付",
|
||||
"clearFile": "ファイルをクリア"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "ファイルを追加…",
|
||||
"pasted": "貼り付けたテキスト",
|
||||
"attached": "ファイル添付済み",
|
||||
"dropToAdd": "ここにファイルをドロップして追加",
|
||||
"dropToReplace": "ここにドロップしてファイルを置き換え"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "画像",
|
||||
"dropRejected": "ファイルが拒否されました",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Jira Cloud에 필요합니다.",
|
||||
"lumappsServiceUser": "대신 인덱싱할 사용자입니다.",
|
||||
"outlineBaseUrl": "예: https://app.getoutline.com 또는 자체 호스팅 URL.",
|
||||
"pfxFile": "PFX 파일입니다.",
|
||||
"pfxFile": "앱 등록의 개인 키가 포함된 `.pfx` 파일을 업로드하세요. 10KB 미만이어야 합니다.",
|
||||
"testrailBaseUrl": "예: https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "년"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "파일 첨부",
|
||||
"clearFile": "파일 지우기"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "파일 추가…",
|
||||
"pasted": "붙여넣은 텍스트",
|
||||
"attached": "파일 첨부됨",
|
||||
"dropToAdd": "여기에 파일을 놓아 추가",
|
||||
"dropToReplace": "여기에 놓아 파일 바꾸기"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "이미지",
|
||||
"dropRejected": "파일이 거부되었습니다",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Obrigatório para o Jira Cloud.",
|
||||
"lumappsServiceUser": "O usuário em nome do qual indexar.",
|
||||
"outlineBaseUrl": "Por exemplo, https://app.getoutline.com ou a URL da sua instância própria.",
|
||||
"pfxFile": "Um arquivo PFX.",
|
||||
"pfxFile": "Envie o arquivo `.pfx` com a chave privada do registro do aplicativo. Deve ter menos de 10 KB.",
|
||||
"testrailBaseUrl": "Por exemplo, https://yourcompany.testrail.io."
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "Ano"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "Anexar arquivo",
|
||||
"clearFile": "Limpar arquivo"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "Adicionar um arquivo…",
|
||||
"pasted": "Texto colado",
|
||||
"attached": "Arquivo anexado",
|
||||
"dropToAdd": "Solte o arquivo aqui para adicionar",
|
||||
"dropToReplace": "Solte aqui para substituir o arquivo"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "Imagem",
|
||||
"dropRejected": "Arquivo rejeitado",
|
||||
|
||||
@@ -3519,7 +3519,7 @@
|
||||
"jiraUserEmail": "Jira Cloud 必填。",
|
||||
"lumappsServiceUser": "要代表其建立索引的用户。",
|
||||
"outlineBaseUrl": "例如 https://app.getoutline.com,或你的自托管 URL。",
|
||||
"pfxFile": "PFX 文件。",
|
||||
"pfxFile": "上传包含应用注册私钥的 `.pfx` 文件。必须小于 10 KB。",
|
||||
"testrailBaseUrl": "例如 https://yourcompany.testrail.io。"
|
||||
},
|
||||
"methods": {
|
||||
@@ -14161,9 +14161,15 @@
|
||||
"year": "年"
|
||||
},
|
||||
"inputFile": {
|
||||
"attachFile": "附加文件",
|
||||
"clearFile": "清除文件"
|
||||
},
|
||||
"inputUpload": {
|
||||
"placeholder": "添加文件…",
|
||||
"pasted": "已粘贴的文本",
|
||||
"attached": "已附加文件",
|
||||
"dropToAdd": "将文件拖放到此处以添加",
|
||||
"dropToReplace": "拖放到此处以替换文件"
|
||||
},
|
||||
"inputImage": {
|
||||
"alt": "图片",
|
||||
"dropRejected": "文件被拒绝",
|
||||
|
||||
@@ -11,8 +11,6 @@ export const FILE_TYPE_DEFINITIONS: Record<
|
||||
maxSizeKB: 10,
|
||||
allowedExtensions: [".pfx"],
|
||||
},
|
||||
description:
|
||||
"Please upload the .pfx file containing the private key of the app registration. The file size must be under 10KB.",
|
||||
},
|
||||
[FileTypeCategory.ONEDRIVE_PFX_FILE]: {
|
||||
category: FileTypeCategory.ONEDRIVE_PFX_FILE,
|
||||
@@ -20,8 +18,6 @@ export const FILE_TYPE_DEFINITIONS: Record<
|
||||
maxSizeKB: 10,
|
||||
allowedExtensions: [".pfx"],
|
||||
},
|
||||
description:
|
||||
"Please upload a .pfx file containing the private key for OneDrive. The file size must be under 10KB.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,5 +12,4 @@ export interface FileValidationRule {
|
||||
export interface FileTypeDefinition {
|
||||
category: FileTypeCategory;
|
||||
validation?: FileValidationRule;
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InputTypeIn } from "@opal/components";
|
||||
import { InputVertical } from "@opal/layouts";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
|
||||
interface AccountNameFieldProps {
|
||||
/** The Formik path of the name, e.g. `name` or `new_account.name`. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A new account's optional display name. Left blank, it shows as "Untitled". */
|
||||
export function AccountNameField({ name }: AccountNameFieldProps) {
|
||||
const t = useTranslations("admin.credentials.create");
|
||||
return (
|
||||
<InputVertical
|
||||
withLabel={name}
|
||||
title={t("nameField.title")}
|
||||
suffix="optional"
|
||||
>
|
||||
<FormikField<string>
|
||||
name={name}
|
||||
render={(formikField) => (
|
||||
<InputTypeIn
|
||||
{...formikField}
|
||||
id={name}
|
||||
value={formikField.value ?? ""}
|
||||
placeholder={t("nameField.placeholder")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</InputVertical>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen, setupUser, waitFor } from "@tests/setup/test-utils";
|
||||
import CreateCredential from "@/lib/credentials/components/CreateCredential";
|
||||
import { ValidSources } from "@/lib/connectors/types/source";
|
||||
|
||||
jest.mock("@/lib/credentials/svc", () => ({
|
||||
submitCredential: jest.fn(),
|
||||
}));
|
||||
|
||||
const { submitCredential } = jest.requireMock("@/lib/credentials/svc");
|
||||
|
||||
function renderNotion() {
|
||||
render(
|
||||
<CreateCredential
|
||||
sourceType={ValidSources.Notion}
|
||||
accessType="public"
|
||||
refresh={jest.fn()}
|
||||
onSwitch={jest.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateCredential", () => {
|
||||
beforeEach(() => {
|
||||
submitCredential.mockResolvedValue({
|
||||
message: "ok",
|
||||
isSuccess: true,
|
||||
credential: { id: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the account's display name", async () => {
|
||||
const user = setupUser();
|
||||
renderNotion();
|
||||
|
||||
await user.type(screen.getByTestId("notion_integration_token"), "secret");
|
||||
await user.type(screen.getByLabelText(/Name/), " Team workspace ");
|
||||
await user.click(screen.getByRole("button", { name: /Create/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(submitCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Team workspace",
|
||||
credential_json: { notion_integration_token: "secret" },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a blank name out", async () => {
|
||||
const user = setupUser();
|
||||
renderNotion();
|
||||
|
||||
await user.type(screen.getByTestId("notion_integration_token"), "secret");
|
||||
await user.click(screen.getByRole("button", { name: /Create/ }));
|
||||
|
||||
await waitFor(() => expect(submitCredential).toHaveBeenCalled());
|
||||
expect(submitCredential.mock.calls[0][0]).not.toHaveProperty("name");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/credentials/components/ShareAccountField";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
import { CredentialFieldsRenderer } from "@/lib/credentials/components/CredentialFieldsRenderer";
|
||||
import { AccountNameField } from "@/lib/credentials/components/AccountNameField";
|
||||
import { TypedFile } from "@/lib/connectors/fileTypes";
|
||||
import { SvgPlusCircle } from "@opal/icons";
|
||||
|
||||
@@ -51,6 +52,8 @@ function CreateButton({ onClick, isSubmitting, isValid }: CreateButtonProps) {
|
||||
}
|
||||
|
||||
type CreateCredentialFormValues = ShareAccountFormValues & {
|
||||
/** The account's display name; blank leaves it "Untitled". */
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -110,7 +113,8 @@ export default function CreateCredential({
|
||||
setSubmitting(true);
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
const { share, groups, ...credentialValues } = values;
|
||||
const { share, groups, name, ...credentialValues } = values;
|
||||
const displayName: string = name.trim();
|
||||
|
||||
let privateKey: TypedFile | null = null;
|
||||
const filteredCredentialValues = Object.fromEntries(
|
||||
@@ -127,7 +131,7 @@ export default function CreateCredential({
|
||||
const response = await submitCredential({
|
||||
credential_json: filteredCredentialValues,
|
||||
...shareAccountPayload({ share, groups }),
|
||||
// No name: the credential list shows its "Untitled" fallback.
|
||||
...(displayName && { name: displayName }),
|
||||
source: sourceType,
|
||||
private_key: privateKey || undefined,
|
||||
});
|
||||
@@ -199,6 +203,7 @@ export default function CreateCredential({
|
||||
<Formik<CreateCredentialFormValues>
|
||||
initialValues={{
|
||||
...initialCredentialValues(spec),
|
||||
name: "",
|
||||
share: DEFAULT_SHARE_AUDIENCE,
|
||||
groups: [],
|
||||
...(initialAuthMethod && {
|
||||
@@ -231,15 +236,18 @@ export default function CreateCredential({
|
||||
setAuthMethod={setAuthMethod}
|
||||
/>
|
||||
|
||||
{/* Above: the fields the source needs. Below: optional sharing
|
||||
and the Create button. */}
|
||||
{/* Above: the fields the source needs. Below: the optional name
|
||||
and sharing, then the Create button. */}
|
||||
<Divider paddingParallel={0} paddingPerpendicular={0} />
|
||||
|
||||
<Disabled disabled={!formikProps.isValid}>
|
||||
<ShareAccountField
|
||||
disabled={!formikProps.isValid}
|
||||
groupsLocked={!businessTier}
|
||||
/>
|
||||
<Section alignItems="stretch" gap={4}>
|
||||
<AccountNameField name="name" />
|
||||
<ShareAccountField
|
||||
disabled={!formikProps.isValid}
|
||||
groupsLocked={!businessTier}
|
||||
/>
|
||||
</Section>
|
||||
</Disabled>
|
||||
|
||||
<Section flexDirection="row" justifyContent="end">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
InputUpload,
|
||||
InputPasswordTypeIn,
|
||||
InputSwitch,
|
||||
InputTypeIn,
|
||||
@@ -10,19 +11,29 @@ import {
|
||||
} from "@opal/components";
|
||||
import {
|
||||
Content,
|
||||
InputErrorText,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
Label,
|
||||
Section,
|
||||
} from "@opal/layouts";
|
||||
import { InputCheckboxField } from "@opal/form";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { getIn, useFormikContext } from "formik";
|
||||
import type { IconFunctionComponent, RichStr } from "@opal/types";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { getIn, useField, useFormikContext } from "formik";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TypedFileUploadFormField } from "@/components/Field";
|
||||
import {
|
||||
FILE_TYPE_DEFINITIONS,
|
||||
type TypedFile,
|
||||
} from "@/lib/connectors/fileTypes";
|
||||
import { createTypedFile } from "@/lib/connectors/utils";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
import { getCredentialSpec, methodFields } from "@/lib/credentials/utils";
|
||||
import {
|
||||
getCredentialFileType,
|
||||
getCredentialSpec,
|
||||
methodFields,
|
||||
} from "@/lib/credentials/utils";
|
||||
import type {
|
||||
CredentialFieldValues,
|
||||
CredentialSpec,
|
||||
@@ -70,10 +81,15 @@ function CredentialField({
|
||||
const name: string = namePrefix ? `${namePrefix}.${fieldKey}` : fieldKey;
|
||||
const checkboxIcon = useCheckboxIcon(name, label);
|
||||
|
||||
// A file such as a .pfx key is binary; Opal's InputFile reads text, so
|
||||
// this field keeps the typed upload until Opal can hand back a File.
|
||||
if (field.kind === "file") {
|
||||
return <TypedFileUploadFormField name={name} label={label} />;
|
||||
return (
|
||||
<CredentialFileField
|
||||
name={name}
|
||||
fieldKey={fieldKey}
|
||||
label={label}
|
||||
description={copy.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The checkbox stands in for the icon, left of the title. The label around
|
||||
@@ -159,6 +175,79 @@ function CredentialField({
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialFileFieldProps {
|
||||
/** The Formik path. */
|
||||
name: string;
|
||||
/** The spec key: it picks the file's type and names it to the server. */
|
||||
fieldKey: string;
|
||||
label: string | RichStr;
|
||||
/**
|
||||
* The field's hint. A plain string is read as markdown, so `.pfx` in
|
||||
* backticks shows as code.
|
||||
*/
|
||||
description?: string | RichStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* A key file, such as a .pfx certificate. It stays binary: the field holds a
|
||||
* `TypedFile`, checked against its type's size and extensions when picked.
|
||||
*/
|
||||
export function CredentialFileField({
|
||||
name,
|
||||
fieldKey,
|
||||
label,
|
||||
description,
|
||||
}: CredentialFileFieldProps) {
|
||||
const t = useTranslations("common.field");
|
||||
const [field, , helpers] = useField<TypedFile | null>(name);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const fileType = getCredentialFileType(fieldKey);
|
||||
const definition = fileType ? FILE_TYPE_DEFINITIONS[fileType] : null;
|
||||
|
||||
async function takeFile(file: File | null) {
|
||||
setFileError(null);
|
||||
if (!file) {
|
||||
void helpers.setValue(null);
|
||||
return;
|
||||
}
|
||||
if (!fileType) {
|
||||
setFileError(t("fileValidation.noTypeDefinition", { name: fieldKey }));
|
||||
return;
|
||||
}
|
||||
const typedFile = createTypedFile(file, fieldKey, fileType);
|
||||
const { isValid, errors } = await typedFile.validate();
|
||||
if (!isValid) {
|
||||
setFileError(errors.join(", ") || t("fileValidation.unknownError"));
|
||||
void helpers.setValue(null);
|
||||
return;
|
||||
}
|
||||
void helpers.setValue(typedFile);
|
||||
void helpers.setTouched(true, false);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputVertical
|
||||
withLabel={name}
|
||||
title={label}
|
||||
subDescription={
|
||||
typeof description === "string" ? markdown(description) : description
|
||||
}
|
||||
>
|
||||
<InputUpload
|
||||
mode="file"
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={fieldKey}
|
||||
accept={definition?.validation?.allowedExtensions?.join(",")}
|
||||
value={field.value?.file ?? null}
|
||||
onFileChange={(file) => void takeFile(file)}
|
||||
variant={fileError ? "error" : "primary"}
|
||||
/>
|
||||
{fileError && <InputErrorText type="error">{fileError}</InputErrorText>}
|
||||
</InputVertical>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialFieldsRendererProps {
|
||||
source: ValidSources;
|
||||
spec: CredentialSpec;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TextFormField, TypedFileUploadFormField } from "@/components/Field";
|
||||
import { TextFormField } from "@/components/Field";
|
||||
import { CredentialFileField } from "@/lib/credentials/components/CredentialFieldsRenderer";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { toast } from "@opal/layouts";
|
||||
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
|
||||
@@ -101,10 +102,12 @@ export default function EditCredential({
|
||||
|
||||
{Object.entries(editableCredentialFields).map(([key, value]) =>
|
||||
getCredentialFileType(key) !== null ? (
|
||||
<TypedFileUploadFormField
|
||||
<CredentialFileField
|
||||
key={key}
|
||||
name={key}
|
||||
fieldKey={key}
|
||||
label={fieldCopy(key).title}
|
||||
description={fieldCopy(key).description}
|
||||
/>
|
||||
) : (
|
||||
<TextFormField
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FileTypeCategory } from "@/lib/connectors/types/fileTypes";
|
||||
import type {
|
||||
CredentialDisplayName,
|
||||
CredentialFieldOptions,
|
||||
CredentialHint,
|
||||
CredentialJsonOf,
|
||||
CredentialMethodDescription,
|
||||
CredentialMethodLabel,
|
||||
@@ -108,7 +107,6 @@ interface MicrosoftAppSpecOptions {
|
||||
brandName: string;
|
||||
fileType: FileTypeCategory;
|
||||
privateKeyDisplayName: CredentialDisplayName;
|
||||
privateKeyHint?: CredentialHint;
|
||||
certificateLabel: CredentialMethodLabel;
|
||||
descriptions?: {
|
||||
clientSecret: CredentialMethodDescription;
|
||||
@@ -142,7 +140,8 @@ function microsoftAppSpec<const TPrefix extends string>(
|
||||
[key("private_key")]: file(
|
||||
options.privateKeyDisplayName,
|
||||
options.fileType,
|
||||
options.privateKeyHint ? { hint: options.privateKeyHint } : {}
|
||||
// Every Microsoft source takes the same .pfx certificate.
|
||||
{ hint: { key: "pfxFile" } }
|
||||
),
|
||||
} as MicrosoftAppFields<TPrefix>;
|
||||
|
||||
@@ -370,7 +369,6 @@ export const CREDENTIAL_SPECS = {
|
||||
brandName: "Microsoft Teams",
|
||||
fileType: FileTypeCategory.SHAREPOINT_PFX_FILE,
|
||||
privateKeyDisplayName: "privateKey",
|
||||
privateKeyHint: { key: "pfxFile" },
|
||||
certificateLabel: "certificateAuthentication",
|
||||
descriptions: {
|
||||
clientSecret: "teamsClientSecret",
|
||||
@@ -381,7 +379,6 @@ export const CREDENTIAL_SPECS = {
|
||||
brandName: "Microsoft Outlook",
|
||||
fileType: FileTypeCategory.SHAREPOINT_PFX_FILE,
|
||||
privateKeyDisplayName: "privateKey",
|
||||
privateKeyHint: { key: "pfxFile" },
|
||||
certificateLabel: "certificateAuthentication",
|
||||
descriptions: {
|
||||
clientSecret: "outlookClientSecret",
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface FileDropzoneFieldProps {
|
||||
/**
|
||||
* 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.
|
||||
* binary. For one file in a form row, use Opal's InputUpload.
|
||||
*/
|
||||
export default function FileDropzoneField({
|
||||
name,
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useFormikContext } from "formik";
|
||||
import { FileUploadFormField } from "@/components/Field";
|
||||
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
import { InputSingleSelectField } from "@opal/form";
|
||||
import { InputUploadField, InputSingleSelectField } from "@opal/form";
|
||||
import { Card, MessageCard } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { InputDivider, InputPadder, InputVertical, toast } from "@opal/layouts";
|
||||
@@ -134,7 +133,10 @@ function VertexAIModalInternals({
|
||||
title={t("vertexAi.credentialsField.title")}
|
||||
subDescription={t("vertexAi.credentialsField.description")}
|
||||
>
|
||||
<FileUploadFormField name={FIELD_VERTEX_CREDENTIALS} label="" />
|
||||
<InputUploadField
|
||||
name={FIELD_VERTEX_CREDENTIALS}
|
||||
accept="application/json,.json"
|
||||
/>
|
||||
</InputVertical>
|
||||
</InputPadder>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as Yup from "yup";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { FormField } from "@/refresh-components/form/FormField";
|
||||
import { InputTypeIn } from "@opal/components";
|
||||
import { InputFile } from "@opal/components";
|
||||
import { InputUploadField } from "@opal/form";
|
||||
import { InputSingleSelect } from "@opal/components";
|
||||
import InlineExternalLink from "@/refresh-components/InlineExternalLink";
|
||||
import { ImageGenFormWrapper } from "@/views/admin/ImageGenerationPage/forms/ImageGenFormWrapper";
|
||||
@@ -167,7 +167,7 @@ function VertexFormFields(
|
||||
{!isWorkloadIdentity && (
|
||||
<FormikField<string>
|
||||
name="custom_config.vertex_credentials"
|
||||
render={(field, helper, meta, state) => (
|
||||
render={(_field, _helper, meta, state) => (
|
||||
<FormField
|
||||
name="custom_config.vertex_credentials"
|
||||
state={apiStatus === "error" ? "error" : state}
|
||||
@@ -175,12 +175,17 @@ function VertexFormFields(
|
||||
>
|
||||
<FormField.Label>{t("form.credentials.label")}</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputFile
|
||||
setValue={(value) => helper.setValue(value)}
|
||||
error={apiStatus === "error"}
|
||||
onBlur={field.onBlur}
|
||||
disabled={disabled}
|
||||
accept="application/json"
|
||||
<InputUploadField
|
||||
name="custom_config.vertex_credentials"
|
||||
// Unset, the field shows its own Formik error.
|
||||
variant={
|
||||
disabled
|
||||
? "disabled"
|
||||
: apiStatus === "error"
|
||||
? "error"
|
||||
: undefined
|
||||
}
|
||||
accept="application/json,.json"
|
||||
placeholder={t("form.credentials.placeholder")}
|
||||
/>
|
||||
</FormField.Control>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useField } from "formik";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettings } from "@/lib/settings/hooks";
|
||||
import * as Yup from "yup";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { Divider, InputFile } from "@opal/components";
|
||||
import { Divider } from "@opal/components";
|
||||
import { InputUploadField } from "@opal/form";
|
||||
import type { RichStr } from "@opal/types";
|
||||
import { InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import type {
|
||||
@@ -74,27 +74,12 @@ export function ApiUrlField({
|
||||
|
||||
export function GoogleCredentialsField() {
|
||||
const t = useTranslations("admin.indexSettings");
|
||||
const [, meta, helpers] = useField<string>("apiKey");
|
||||
return (
|
||||
<InputVertical
|
||||
title={t("fields.googleCredentials.title")}
|
||||
withLabel="apiKey"
|
||||
>
|
||||
<InputFile
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
error={meta.touched && !!meta.error}
|
||||
setValue={(value) => {
|
||||
void helpers.setValue(value);
|
||||
}}
|
||||
onValueSet={() => {
|
||||
void helpers.setTouched(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
void helpers.setTouched(true);
|
||||
}}
|
||||
accept=".json"
|
||||
/>
|
||||
<InputUploadField name="apiKey" accept=".json" />
|
||||
</InputVertical>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Form, Formik } from "formik";
|
||||
import { User } from "@/lib/types";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { Section, toast } from "@opal/layouts";
|
||||
import { InputFile } from "@opal/components";
|
||||
import { InputUpload } from "@opal/components";
|
||||
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
import { refreshAllGoogleData } from "@/lib/googleConnector";
|
||||
import { parseOauthAppCredentialJson } from "@/lib/credentials/utils";
|
||||
@@ -80,8 +80,8 @@ export const DriveAuthSection = ({
|
||||
})
|
||||
)}
|
||||
</Text>
|
||||
<InputFile
|
||||
accept="application/json"
|
||||
<InputUpload
|
||||
accept="application/json,.json"
|
||||
placeholder={t("gdrive.oauthUpload.placeholder")}
|
||||
setValue={(value) => {
|
||||
setOauthAppCredential(
|
||||
@@ -125,8 +125,8 @@ export const DriveAuthSection = ({
|
||||
<Text as="p" font="main-ui-action">
|
||||
{t("gdrive.serviceAccountOption.title")}
|
||||
</Text>
|
||||
<InputFile
|
||||
accept="application/json"
|
||||
<InputUpload
|
||||
accept="application/json,.json"
|
||||
placeholder={t("gdrive.serviceAccountUpload.placeholder")}
|
||||
setValue={(value) => {
|
||||
if (!value) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { Section, toast } from "@opal/layouts";
|
||||
import { InputFile } from "@opal/components";
|
||||
import { InputUpload } from "@opal/components";
|
||||
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -86,8 +86,8 @@ export const GmailAuthSection = ({
|
||||
})
|
||||
)}
|
||||
</Text>
|
||||
<InputFile
|
||||
accept="application/json"
|
||||
<InputUpload
|
||||
accept="application/json,.json"
|
||||
placeholder={t("gmail.oauthUpload.placeholder")}
|
||||
setValue={(value) => {
|
||||
setOauthAppCredential(
|
||||
@@ -136,8 +136,8 @@ export const GmailAuthSection = ({
|
||||
<Text as="p" font="main-ui-action">
|
||||
{t("gmail.serviceAccountOption.title")}
|
||||
</Text>
|
||||
<InputFile
|
||||
accept="application/json"
|
||||
<InputUpload
|
||||
accept="application/json,.json"
|
||||
placeholder={t("gmail.serviceAccountUpload.placeholder")}
|
||||
setValue={(value) => {
|
||||
if (!value) {
|
||||
|
||||
+4
-34
@@ -2,24 +2,10 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Fold,
|
||||
InputTypeIn,
|
||||
SelectCard,
|
||||
Tabs,
|
||||
} from "@opal/components";
|
||||
import {
|
||||
Content,
|
||||
ContentAction,
|
||||
InputVertical,
|
||||
Section,
|
||||
toast,
|
||||
} from "@opal/layouts";
|
||||
import { Button, Divider, Fold, SelectCard, Tabs } from "@opal/components";
|
||||
import { Content, ContentAction, Section, toast } from "@opal/layouts";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { cn } from "@opal/utils";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { SvgExpand, SvgFold, SvgListTree, SvgPlusCircle } from "@opal/icons";
|
||||
import type {
|
||||
Credential,
|
||||
@@ -34,6 +20,7 @@ import { useSettings } from "@/lib/settings/hooks";
|
||||
import CreateCredential from "@/lib/credentials/components/CreateCredential";
|
||||
import { CredentialFieldsRenderer } from "@/lib/credentials/components/CredentialFieldsRenderer";
|
||||
import { ShareAccountField } from "@/lib/credentials/components/ShareAccountField";
|
||||
import { AccountNameField } from "@/lib/credentials/components/AccountNameField";
|
||||
import { updateCredentialSharing } from "@/lib/credentials/svc";
|
||||
import { getIn, useFormikContext } from "formik";
|
||||
import { useTierAtLeast } from "@/lib/tiers/hooks";
|
||||
@@ -102,7 +89,6 @@ export default function AuthenticationAccountSection({
|
||||
editCCPairId = null,
|
||||
}: AuthenticationAccountSectionProps) {
|
||||
const t = useTranslations("admin.connectorsList");
|
||||
const tCreate = useTranslations("admin.credentials.create");
|
||||
const newAccountNameField = `${NEW_ACCOUNT_FIELD}.${NEW_ACCOUNT_NAME_FIELD}`;
|
||||
const settings = useSettings();
|
||||
const {
|
||||
@@ -278,23 +264,7 @@ export default function AuthenticationAccountSection({
|
||||
{/* Opens once the account's own fields are valid. */}
|
||||
<Disabled disabled={!newAccountReady}>
|
||||
<Section alignItems="stretch" gap={4}>
|
||||
<InputVertical
|
||||
withLabel={newAccountNameField}
|
||||
title={tCreate("nameField.title")}
|
||||
suffix="optional"
|
||||
>
|
||||
<FormikField<string>
|
||||
name={newAccountNameField}
|
||||
render={(formikField) => (
|
||||
<InputTypeIn
|
||||
{...formikField}
|
||||
id={newAccountNameField}
|
||||
value={formikField.value ?? ""}
|
||||
placeholder={tCreate("nameField.placeholder")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</InputVertical>
|
||||
<AccountNameField name={newAccountNameField} />
|
||||
<ShareAccountField
|
||||
namePrefix={NEW_ACCOUNT_FIELD}
|
||||
disabled={!newAccountReady}
|
||||
|
||||
Reference in new issue
Block a user