1
0
Fork 0
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:
Chaim Lev-Ari 2023-05-31 10:08:41 +07:00 committed by GitHub
parent 6b5940e00e
commit 2d05103fed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 721 additions and 442 deletions

View file

@ -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));
}
}

View file

@ -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(''),
})
);
}

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -0,0 +1,8 @@
export {
EnvironmentVariablesFieldset,
envVarValidation,
} from './EnvironmentVariablesFieldset';
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
export { type Value as EnvVarValues } from './types';

View file

@ -0,0 +1,6 @@
export interface EnvVar {
name: string;
value?: string;
}
export type Value = Array<EnvVar>;

View file

@ -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
);
}

View file

@ -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>
);

View file

@ -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)}
/>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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} />
);
}