mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(ui): migrate env var field to react [EE-4853] (#8451)
This commit is contained in:
parent
6b5940e00e
commit
2d05103fed
40 changed files with 721 additions and 442 deletions
|
@ -0,0 +1,50 @@
|
|||
import { List } from 'lucide-react';
|
||||
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { convertToArrayOfStrings, parseDotEnvFile } from './utils';
|
||||
import { type Value } from './types';
|
||||
|
||||
export function AdvancedMode({
|
||||
value,
|
||||
onChange,
|
||||
onSimpleModeClick,
|
||||
}: {
|
||||
value: Value;
|
||||
onChange: (value: Value) => void;
|
||||
onSimpleModeClick: () => void;
|
||||
}) {
|
||||
const editorValue = convertToArrayOfStrings(value).join('\n');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
color="link"
|
||||
icon={List}
|
||||
className="!ml-0 p-0 hover:no-underline"
|
||||
onClick={onSimpleModeClick}
|
||||
>
|
||||
Simple mode
|
||||
</Button>
|
||||
|
||||
<TextTip color="blue" inline={false}>
|
||||
Switch to simple mode to define variables line by line, or load from
|
||||
.env file
|
||||
</TextTip>
|
||||
|
||||
<CodeEditor
|
||||
id="environment-variables-editor"
|
||||
value={editorValue}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="e.g. key=value"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleEditorChange(value: string) {
|
||||
onChange(parseDotEnvFile(value));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { useState } from 'react';
|
||||
import { array, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { ArrayError } from '../InputList/InputList';
|
||||
|
||||
import { AdvancedMode } from './AdvancedMode';
|
||||
import { SimpleMode } from './SimpleMode';
|
||||
import { Value } from './types';
|
||||
|
||||
export function EnvironmentVariablesFieldset({
|
||||
onChange,
|
||||
values,
|
||||
errors,
|
||||
}: {
|
||||
values: Value;
|
||||
onChange(value: Value): void;
|
||||
errors?: ArrayError<Value>;
|
||||
}) {
|
||||
const [simpleMode, setSimpleMode] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="col-sm-12">
|
||||
{simpleMode ? (
|
||||
<SimpleMode
|
||||
onAdvancedModeClick={() => setSimpleMode(false)}
|
||||
onChange={onChange}
|
||||
value={values}
|
||||
errors={errors}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedMode
|
||||
onSimpleModeClick={() => setSimpleMode(true)}
|
||||
onChange={onChange}
|
||||
value={values}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function envVarValidation(): SchemaOf<Value> {
|
||||
return array(
|
||||
object({
|
||||
name: string().required('Name is required'),
|
||||
value: string().default(''),
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ArrayError } from '../InputList/InputList';
|
||||
|
||||
import { Value } from './types';
|
||||
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
|
||||
|
||||
export function EnvironmentVariablesPanel({
|
||||
explanation,
|
||||
onChange,
|
||||
values,
|
||||
showHelpMessage,
|
||||
errors,
|
||||
}: {
|
||||
explanation?: string;
|
||||
values: Value;
|
||||
onChange(value: Value): void;
|
||||
showHelpMessage?: boolean;
|
||||
errors?: ArrayError<Value>;
|
||||
}) {
|
||||
return (
|
||||
<FormSection title="Environment variables">
|
||||
<div className="form-group">
|
||||
{!!explanation && (
|
||||
<div className="col-sm-12 environment-variables-panel--explanation">
|
||||
{explanation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EnvironmentVariablesFieldset
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
{showHelpMessage && (
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue" inline={false}>
|
||||
Environment changes will not take effect until redeployment occurs
|
||||
manually or via webhook.
|
||||
</TextTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import { Edit, Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { readFileAsText } from '@/portainer/services/fileUploadReact';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FileUploadField } from '@@/form-components/FileUpload';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList';
|
||||
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||
|
||||
import { FormError } from '../FormError';
|
||||
|
||||
import { type EnvVar, type Value } from './types';
|
||||
import { parseDotEnvFile } from './utils';
|
||||
|
||||
export function SimpleMode({
|
||||
value,
|
||||
onChange,
|
||||
onAdvancedModeClick,
|
||||
errors,
|
||||
}: {
|
||||
value: Value;
|
||||
onChange: (value: Value) => void;
|
||||
onAdvancedModeClick: () => void;
|
||||
errors?: ArrayError<Value>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
color="link"
|
||||
icon={Edit}
|
||||
className="!ml-0 p-0 hover:no-underline"
|
||||
onClick={onAdvancedModeClick}
|
||||
>
|
||||
Advanced mode
|
||||
</Button>
|
||||
|
||||
<TextTip color="blue" inline={false}>
|
||||
Switch to advanced mode to copy & paste multiple variables
|
||||
</TextTip>
|
||||
|
||||
<InputList
|
||||
aria-label="environment variables list"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
isAddButtonHidden
|
||||
item={Item}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => onChange([...value, { name: '', value: '' }])}
|
||||
color="default"
|
||||
icon={Plus}
|
||||
>
|
||||
Add an environment variable
|
||||
</Button>
|
||||
|
||||
<FileEnv onChooseFile={(add) => onChange([...value, ...add])} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
readOnly,
|
||||
index,
|
||||
}: ItemProps<EnvVar>) {
|
||||
return (
|
||||
<div className="relative flex w-full flex-col">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<InputLabeled
|
||||
className="w-1/2"
|
||||
label="name"
|
||||
value={item.name}
|
||||
onChange={(e) => handleChange({ name: e.target.value })}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder="e.g. FOO"
|
||||
size="small"
|
||||
id={`env-name${index}`}
|
||||
/>
|
||||
<InputLabeled
|
||||
className="w-1/2"
|
||||
label="value"
|
||||
value={item.value}
|
||||
onChange={(e) => handleChange({ value: e.target.value })}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder="e.g. bar"
|
||||
size="small"
|
||||
id={`env-value${index}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!!error && (
|
||||
<div className="absolute -bottom-5">
|
||||
<FormError className="m-0">{Object.values(error)[0]}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(partial: Partial<EnvVar>) {
|
||||
onChange({ ...item, ...partial });
|
||||
}
|
||||
}
|
||||
|
||||
function FileEnv({ onChooseFile }: { onChooseFile: (file: Value) => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const fileTooBig = file && file.size > 1024 * 1024;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileUploadField
|
||||
inputId="env-file-upload"
|
||||
onChange={handleChange}
|
||||
title="Load variables from .env file"
|
||||
accept=".env"
|
||||
value={file}
|
||||
color="default"
|
||||
/>
|
||||
|
||||
{fileTooBig && (
|
||||
<TextTip color="orange" inline>
|
||||
File too large! Try uploading a file smaller than 1MB
|
||||
</TextTip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
async function handleChange(file: File) {
|
||||
setFile(file);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await readFileAsText(file);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDotEnvFile(text);
|
||||
onChooseFile(parsed);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
EnvironmentVariablesFieldset,
|
||||
envVarValidation,
|
||||
} from './EnvironmentVariablesFieldset';
|
||||
|
||||
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
|
||||
|
||||
export { type Value as EnvVarValues } from './types';
|
|
@ -0,0 +1,6 @@
|
|||
export interface EnvVar {
|
||||
name: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type Value = Array<EnvVar>;
|
|
@ -0,0 +1,53 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { EnvVar } from './types';
|
||||
|
||||
export const KEY_REGEX = /(.+?)/.source;
|
||||
export const VALUE_REGEX = /(.*)?/.source;
|
||||
|
||||
const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`);
|
||||
const NEWLINES_REGEX = /\n|\r|\r\n/;
|
||||
|
||||
export function parseDotEnvFile(src: string) {
|
||||
return parseArrayOfStrings(
|
||||
_.compact(src.split(NEWLINES_REGEX))
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => !v.startsWith('#') && v !== '')
|
||||
);
|
||||
}
|
||||
|
||||
export function parseArrayOfStrings(array: Array<string> = []): Array<EnvVar> {
|
||||
if (!array) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
array.map((variableString) => {
|
||||
if (!variableString.includes('=')) {
|
||||
return { name: variableString };
|
||||
}
|
||||
|
||||
const parsedKeyValArr = variableString.trim().match(KEY_VALUE_REGEX);
|
||||
if (parsedKeyValArr == null || parsedKeyValArr.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsedKeyValArr[1].trim(),
|
||||
value: parsedKeyValArr[3].trim() || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function convertToArrayOfStrings(array: Array<EnvVar>) {
|
||||
if (!array) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array
|
||||
.filter((variable) => variable.name)
|
||||
.map(({ name, value }) =>
|
||||
value || value === '' ? `${name}=${value}` : name
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { ChangeEvent, createRef } from 'react';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { ChangeEvent, ComponentProps, createRef } from 'react';
|
||||
import { Upload, XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
@ -8,11 +8,12 @@ import styles from './FileUploadField.module.css';
|
|||
|
||||
export interface Props {
|
||||
onChange(value: File): void;
|
||||
value?: File;
|
||||
value?: File | null;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
required?: boolean;
|
||||
inputId: string;
|
||||
color?: ComponentProps<typeof Button>['color'];
|
||||
}
|
||||
|
||||
export function FileUploadField({
|
||||
|
@ -22,6 +23,7 @@ export function FileUploadField({
|
|||
title = 'Select a file',
|
||||
required = false,
|
||||
inputId,
|
||||
color = 'primary',
|
||||
}: Props) {
|
||||
const fileRef = createRef<HTMLInputElement>();
|
||||
|
||||
|
@ -39,15 +41,16 @@ export function FileUploadField({
|
|||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
color={color}
|
||||
onClick={handleButtonClick}
|
||||
className={styles.fileButton}
|
||||
icon={Upload}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<span className="vertical-center">
|
||||
{value ? value.name : <Icon icon={XCircle} mode="danger" />}
|
||||
{value ? value.name : required && <Icon icon={XCircle} mode="danger" />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InputLabeled } from './InputLabeled';
|
||||
|
||||
export default {
|
||||
component: InputLabeled,
|
||||
title: 'Components/Form/InputLabeled',
|
||||
} as Meta;
|
||||
|
||||
export { TextInput, NumberInput };
|
||||
|
||||
function TextInput() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<InputLabeled
|
||||
label="label"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput() {
|
||||
const [value, setValue] = useState(5);
|
||||
|
||||
return (
|
||||
<InputLabeled
|
||||
label="label"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.valueAsNumber)}
|
||||
/>
|
||||
);
|
||||
}
|
28
app/react/components/form-components/Input/InputLabeled.tsx
Normal file
28
app/react/components/form-components/Input/InputLabeled.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { ComponentProps, InputHTMLAttributes } from 'react';
|
||||
|
||||
import { InputGroup } from '../InputGroup';
|
||||
|
||||
export function InputLabeled({
|
||||
label,
|
||||
className,
|
||||
size,
|
||||
id,
|
||||
...props
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
size?: ComponentProps<typeof InputGroup>['size'];
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'children'>) {
|
||||
return (
|
||||
<InputGroup className={className} size={size}>
|
||||
<InputGroup.Addon as="label" htmlFor={id}>
|
||||
{label}
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
id={id}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,19 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
import { ComponentType, PropsWithChildren } from 'react';
|
||||
|
||||
import { useInputGroupContext } from './InputGroup';
|
||||
|
||||
export function InputGroupAddon({ children }: PropsWithChildren<unknown>) {
|
||||
export function InputGroupAddon<TProps>({
|
||||
children,
|
||||
as = 'span',
|
||||
...props
|
||||
}: PropsWithChildren<{ as?: ComponentType<TProps> | string } & TProps>) {
|
||||
useInputGroupContext();
|
||||
const Component = as as 'span';
|
||||
|
||||
return <span className="input-group-addon">{children}</span>;
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<Component className="input-group-addon" {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,12 +13,25 @@ import { FormError } from '../FormError';
|
|||
import styles from './InputList.module.css';
|
||||
import { arrayMove } from './utils';
|
||||
|
||||
type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
|
||||
? ElementType
|
||||
: never;
|
||||
|
||||
export type ArrayError<T> =
|
||||
| FormikErrors<ArrElement<T>>[]
|
||||
| string
|
||||
| string[]
|
||||
| undefined;
|
||||
export type ItemError<T> = FormikErrors<T> | string | undefined;
|
||||
|
||||
export interface ItemProps<T> {
|
||||
item: T;
|
||||
onChange(value: T): void;
|
||||
error?: string | FormikErrors<T>;
|
||||
error?: ItemError<T>;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
index: number;
|
||||
}
|
||||
type Key = string | number;
|
||||
type ChangeType = 'delete' | 'create' | 'update';
|
||||
|
@ -38,11 +51,12 @@ type OnChangeEvent<T> =
|
|||
type RenderItemFunction<T> = (
|
||||
item: T,
|
||||
onChange: (value: T) => void,
|
||||
error?: string | FormikErrors<T>
|
||||
index: number,
|
||||
error?: ItemError<T>
|
||||
) => React.ReactNode;
|
||||
|
||||
interface Props<T> {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: T[];
|
||||
onChange(value: T[], e: OnChangeEvent<T>): void;
|
||||
itemBuilder?(): T;
|
||||
|
@ -52,11 +66,12 @@ interface Props<T> {
|
|||
addLabel?: string;
|
||||
itemKeyGetter?(item: T, index: number): Key;
|
||||
movable?: boolean;
|
||||
errors?: FormikErrors<T>[] | string | string[];
|
||||
errors?: ArrayError<T[]>;
|
||||
textTip?: string;
|
||||
isAddButtonHidden?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function InputList<T = DefaultType>({
|
||||
|
@ -75,15 +90,22 @@ export function InputList<T = DefaultType>({
|
|||
isAddButtonHidden = false,
|
||||
disabled,
|
||||
readOnly,
|
||||
'aria-label': ariaLabel,
|
||||
}: Props<T>) {
|
||||
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
||||
return (
|
||||
<div className={clsx('form-group', styles.root)}>
|
||||
<div className={clsx('col-sm-12', styles.header)}>
|
||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||
{label}
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx('form-group', styles.root)}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
{label && (
|
||||
<div className={clsx('col-sm-12', styles.header)}>
|
||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||
{label}
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textTip && (
|
||||
<div className="col-sm-12 mt-5">
|
||||
|
@ -114,11 +136,13 @@ export function InputList<T = DefaultType>({
|
|||
error={error}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
index={index}
|
||||
/>
|
||||
) : (
|
||||
renderItem(
|
||||
item,
|
||||
(value: T) => handleChangeItem(key, value),
|
||||
index,
|
||||
error
|
||||
)
|
||||
)}
|
||||
|
@ -157,8 +181,9 @@ export function InputList<T = DefaultType>({
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="col-sm-12 mt-5">
|
||||
{!(isAddButtonHidden || readOnly) && (
|
||||
|
||||
{isAddButtonVisible && (
|
||||
<div className="col-sm-12 mt-5">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={disabled}
|
||||
|
@ -170,8 +195,8 @@ export function InputList<T = DefaultType>({
|
|||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -259,7 +284,10 @@ function DefaultItem({
|
|||
function renderDefaultItem(
|
||||
item: DefaultType,
|
||||
onChange: (value: DefaultType) => void,
|
||||
error?: FormikErrors<DefaultType>
|
||||
index: number,
|
||||
error?: ItemError<DefaultType>
|
||||
) {
|
||||
return <DefaultItem item={item} onChange={onChange} error={error} />;
|
||||
return (
|
||||
<DefaultItem item={item} onChange={onChange} error={error} index={index} />
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue