refactor(app): migrate env var form section [EE-6232] (#10499)

* refactor(app): migrate env var form section [EE-6232]

* allow undoing delete in inputlist
pull/10528/head
Ali 11 months ago committed by GitHub
parent 6228314e3c
commit 488393007f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -108,14 +108,14 @@ class KubernetesApplicationHelper {
/* #region ENV VARIABLES FV <> ENV */ /* #region ENV VARIABLES FV <> ENV */
static generateEnvFromEnvVariables(envVariables) { static generateEnvFromEnvVariables(envVariables) {
_.remove(envVariables, (item) => item.NeedsDeletion); _.remove(envVariables, (item) => item.needsDeletion);
const env = _.map(envVariables, (item) => { const env = _.map(envVariables, (item) => {
const res = new KubernetesApplicationEnvPayload(); const res = new KubernetesApplicationEnvPayload();
res.name = item.Name; res.name = item.name;
if (item.Value === undefined) { if (item.value === undefined) {
delete res.value; delete res.value;
} else { } else {
res.value = item.Value; res.value = item.value;
} }
return res; return res;
}); });
@ -128,10 +128,10 @@ class KubernetesApplicationHelper {
return; return;
} }
const res = new KubernetesApplicationEnvironmentVariableFormValue(); const res = new KubernetesApplicationEnvironmentVariableFormValue();
res.Name = item.name; res.name = item.name;
res.Value = item.value; res.value = item.value;
res.IsNew = false; res.isNew = false;
res.NameIndex = item.name; res.nameIndex = item.name;
return res; return res;
}); });
return _.without(envVariables, undefined); return _.without(envVariables, undefined);

@ -70,12 +70,11 @@ export class KubernetesApplicationConfigurationFormValue {
* KubernetesApplicationEnvironmentVariableFormValue Model * KubernetesApplicationEnvironmentVariableFormValue Model
*/ */
const _KubernetesApplicationEnvironmentVariableFormValue = Object.freeze({ const _KubernetesApplicationEnvironmentVariableFormValue = Object.freeze({
Name: '', name: '',
Value: '', value: '',
IsSecret: false, needsDeletion: false,
NeedsDeletion: false, isNew: true,
IsNew: true, nameIndex: '', // keep the original name for sorting
NameIndex: '', // keep the original name for sorting
}); });
export class KubernetesApplicationEnvironmentVariableFormValue { export class KubernetesApplicationEnvironmentVariableFormValue {

@ -24,6 +24,9 @@ import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable'; import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable'; import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName'; import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/ApplicationForm/kubeEnvVarValidationSchema';
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
export const ngModule = angular export const ngModule = angular
.module('portainer.kubernetes.react.components', []) .module('portainer.kubernetes.react.components', [])
@ -174,3 +177,12 @@ withFormValidation(
['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'], ['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'],
kubeServicesValidation kubeServicesValidation
); );
withFormValidation(
ngModule,
EnvironmentVariablesFieldset,
'kubeEnvironmentVariablesFieldset',
['canUndoDelete'],
// use kubeEnvVarValidationSchema instead of envVarValidation to add a regex matches rule
kubeEnvVarValidationSchema
);

@ -383,103 +383,12 @@
<div class="col-sm-12 vertical-center"> <div class="col-sm-12 vertical-center">
<label class="control-label !pt-0 text-left">Environment variables</label> <label class="control-label !pt-0 text-left">Environment variables</label>
</div> </div>
<div class="col-sm-11 col-lg-10 mt-2">
<div class="col-sm-12 form-inline mt-2"> <kube-environment-variables-fieldset
<div ng-repeat="envVar in ctrl.formValues.EnvironmentVariables | orderBy: 'NameIndex'" class="mt-2"> values="ctrl.formValues.EnvironmentVariables"
<div style="margin-top: 2px"> on-change="(ctrl.onEnvironmentVariableChange)"
<div class="col-sm-4 input-group input-group-sm mr-2"> can-undo-delete="true"
<div class="input-group col-sm-12 input-group-sm" ng-class="{ striked: envVar.NeedsDeletion }"> ></kube-environment-variables-fieldset>
<span class="input-group-addon required">name</span>
<input
type="text"
name="environment_variable_name_{{ $index }}"
class="form-control"
ng-model="envVar.Name"
ng-change="ctrl.onChangeEnvironmentName()"
ng-pattern="/^[-._a-zA-Z][-._a-zA-Z0-9]*$/"
placeholder="foo"
ng-disabled="ctrl.formValues.Containers.length > 1"
data-cy="k8sAppCreate-envVarName_{{ $index }}"
required
/>
</div>
</div>
<div class="col-sm-4 input-group input-group-sm mr-2" ng-class="{ striked: envVar.NeedsDeletion }">
<span class="input-group-addon">value</span>
<input
type="text"
name="environment_variable_value_{{ $index }}"
class="form-control"
ng-model="envVar.Value"
placeholder="bar"
ng-disabled="ctrl.formValues.Containers.length > 1"
data-cy="k8sAppCreate-envVarValue_{{ $index }}"
/>
</div>
<div class="col-sm-2 input-group input-group-sm" ng-if="ctrl.formValues.Containers.length <= 1">
<button
ng-if="!envVar.NeedsDeletion"
class="btn btn-md btn-dangerlight btn-only-icon !ml-0"
type="button"
ng-click="ctrl.removeEnvironmentVariable(envVar)"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
<button
ng-if="envVar.NeedsDeletion"
class="btn btn-sm btn-light btn-only-icon"
type="button"
ng-click="ctrl.restoreEnvironmentVariable(envVar)"
data-cy="k8sAppCreate-removeEnvVarButton_{{ $index }}"
>
<pr-icon icon="'rotate-cw'" size="'md'"></pr-icon>
</button>
</div>
</div>
<div
ng-show="
kubernetesApplicationCreationForm['environment_variable_name_' + $index].$invalid ||
ctrl.state.duplicates.environmentVariables.refs[$index] !== undefined
"
>
<div class="col-sm-8 input-group input-group-sm">
<div
class="small"
style="margin-top: 5px"
ng-show="
kubernetesApplicationCreationForm['environment_variable_name_' + $index].$invalid ||
ctrl.state.duplicates.environmentVariables.refs[$index] !== undefined
"
>
<ng-messages for="kubernetesApplicationCreationForm['environment_variable_name_' + $index].$error">
<p ng-message="required" class="text-warning vertical-center"
><pr-icon icon="'alert-triangle'" mode="'warning'" class-="vertical-center"></pr-icon> Environment variable name is required.</p
>
<p ng-message="pattern" class="text-warning vertical-center"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field must consist of alphabetic characters, digits, '_', '-', or '.', and must
not start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.</p
>
</ng-messages>
<p class="text-warning vertical-center" ng-if="ctrl.state.duplicates.environmentVariables.refs[$index] !== undefined"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This environment variable is already defined.</p
>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-12 mt-2">
<span
ng-if="ctrl.formValues.Containers.length <= 1"
class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0"
ng-click="ctrl.addEnvironmentVariable()"
data-cy="k8sAppCreate-addEnvVarButton"
>
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add environment variable
</span>
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->

@ -154,6 +154,7 @@ class KubernetesCreateApplicationController {
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this); this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
this.onChangePlacementType = this.onChangePlacementType.bind(this); this.onChangePlacementType = this.onChangePlacementType.bind(this);
this.onServicesChange = this.onServicesChange.bind(this); this.onServicesChange = this.onServicesChange.bind(this);
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
} }
/* #endregion */ /* #endregion */
@ -359,30 +360,14 @@ class KubernetesCreateApplicationController {
/* #endregion */ /* #endregion */
/* #region ENVIRONMENT UI MANAGEMENT */ /* #region ENVIRONMENT UI MANAGEMENT */
addEnvironmentVariable() { onEnvironmentVariableChange(enviromnentVariables) {
this.formValues.EnvironmentVariables.push(new KubernetesApplicationEnvironmentVariableFormValue()); return this.$async(async () => {
} const newEnvVars = enviromnentVariables.map((envVar) => {
const newEnvVar = new KubernetesApplicationEnvironmentVariableFormValue();
restoreEnvironmentVariable(item) { return { newEnvVar, ...envVar };
item.NeedsDeletion = false; });
} this.formValues.EnvironmentVariables = newEnvVars;
});
removeEnvironmentVariable(item) {
const index = this.formValues.EnvironmentVariables.indexOf(item);
if (index !== -1) {
const envVar = this.formValues.EnvironmentVariables[index];
if (!envVar.IsNew) {
envVar.NeedsDeletion = true;
} else {
this.formValues.EnvironmentVariables.splice(index, 1);
}
}
this.onChangeEnvironmentName();
}
onChangeEnvironmentName() {
this.state.duplicates.environmentVariables.refs = KubernetesFormValidationHelper.getDuplicates(_.map(this.formValues.EnvironmentVariables, 'Name'));
this.state.duplicates.environmentVariables.hasRefs = Object.keys(this.state.duplicates.environmentVariables.refs).length > 0;
} }
/* #endregion */ /* #endregion */

@ -242,7 +242,7 @@ withFormValidation(
ngModule, ngModule,
EnvironmentVariablesFieldset, EnvironmentVariablesFieldset,
'environmentVariablesFieldset', 'environmentVariablesFieldset',
[], ['canUndoDelete'],
envVarValidation envVarValidation
); );

@ -0,0 +1,59 @@
import { FormError } from '../FormError';
import { InputLabeled } from '../Input/InputLabeled';
import { ItemProps } from '../InputList';
import { EnvVar } from './types';
export function EnvironmentVariableItem({
item,
onChange,
disabled,
error,
readOnly,
index,
}: ItemProps<EnvVar>) {
return (
<div className="relative flex w-full flex-col">
<div className="flex w-full items-start gap-2">
<div className="w-1/2">
<InputLabeled
className="w-full"
label="name"
required
value={item.name}
onChange={(e) => handleChange({ name: e.target.value })}
disabled={disabled}
needsDeletion={item.needsDeletion}
readOnly={readOnly}
placeholder="e.g. FOO"
size="small"
id={`env-name${index}`}
/>
{error && (
<div>
<FormError className="mt-1 !mb-0">
{Object.values(error)[0]}
</FormError>
</div>
)}
</div>
<InputLabeled
className="w-1/2"
label="value"
value={item.value}
onChange={(e) => handleChange({ value: e.target.value })}
disabled={disabled}
needsDeletion={item.needsDeletion}
readOnly={readOnly}
placeholder="e.g. bar"
size="small"
id={`env-value${index}`}
/>
</div>
</div>
);
function handleChange(partial: Partial<EnvVar>) {
onChange({ ...item, ...partial });
}
}

@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { array, object, SchemaOf, string } from 'yup'; import { array, boolean, object, SchemaOf, string } from 'yup';
import { ArrayError } from '../InputList/InputList'; import { ArrayError } from '../InputList/InputList';
import { buildUniquenessTest } from '../validate-unique';
import { AdvancedMode } from './AdvancedMode'; import { AdvancedMode } from './AdvancedMode';
import { SimpleMode } from './SimpleMode'; import { SimpleMode } from './SimpleMode';
@ -11,21 +12,24 @@ export function EnvironmentVariablesFieldset({
onChange, onChange,
values, values,
errors, errors,
canUndoDelete,
}: { }: {
values: Value; values: Value;
onChange(value: Value): void; onChange(value: Value): void;
errors?: ArrayError<Value>; errors?: ArrayError<Value>;
canUndoDelete?: boolean;
}) { }) {
const [simpleMode, setSimpleMode] = useState(true); const [simpleMode, setSimpleMode] = useState(true);
return ( return (
<div className="col-sm-12"> <>
{simpleMode ? ( {simpleMode ? (
<SimpleMode <SimpleMode
onAdvancedModeClick={() => setSimpleMode(false)} onAdvancedModeClick={() => setSimpleMode(false)}
onChange={onChange} onChange={onChange}
value={values} value={values}
errors={errors} errors={errors}
canUndoDelete={canUndoDelete}
/> />
) : ( ) : (
<AdvancedMode <AdvancedMode
@ -34,7 +38,7 @@ export function EnvironmentVariablesFieldset({
value={values} value={values}
/> />
)} )}
</div> </>
); );
} }
@ -43,6 +47,14 @@ export function envVarValidation(): SchemaOf<Value> {
object({ object({
name: string().required('Name is required'), name: string().required('Name is required'),
value: string().default(''), value: string().default(''),
needsDeletion: boolean().default(false),
}) })
).test(
'unique',
'This environment variable is already defined.',
buildUniquenessTest(
() => 'This environment variable is already defined.',
'name'
)
); );
} }

@ -34,11 +34,13 @@ export function EnvironmentVariablesPanel({
</div> </div>
)} )}
<EnvironmentVariablesFieldset <div className="col-sm-12">
values={values} <EnvironmentVariablesFieldset
onChange={onChange} values={values}
errors={errors} onChange={onChange}
/> errors={errors}
/>
</div>
{showHelpMessage && ( {showHelpMessage && (
<div className="col-sm-12"> <div className="col-sm-12">

@ -7,24 +7,24 @@ import { Button } from '@@/buttons';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { FileUploadField } from '@@/form-components/FileUpload'; import { FileUploadField } from '@@/form-components/FileUpload';
import { InputList } from '@@/form-components/InputList'; import { InputList } from '@@/form-components/InputList';
import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList'; import { ArrayError } from '@@/form-components/InputList/InputList';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
import { FormError } from '../FormError'; import type { Value } from './types';
import { type EnvVar, type Value } from './types';
import { parseDotEnvFile } from './utils'; import { parseDotEnvFile } from './utils';
import { EnvironmentVariableItem } from './EnvironmentVariableItem';
export function SimpleMode({ export function SimpleMode({
value, value,
onChange, onChange,
onAdvancedModeClick, onAdvancedModeClick,
errors, errors,
canUndoDelete,
}: { }: {
value: Value; value: Value;
onChange: (value: Value) => void; onChange: (value: Value) => void;
onAdvancedModeClick: () => void; onAdvancedModeClick: () => void;
errors?: ArrayError<Value>; errors?: ArrayError<Value>;
canUndoDelete?: boolean;
}) { }) {
return ( return (
<> <>
@ -47,13 +47,17 @@ export function SimpleMode({
onChange={onChange} onChange={onChange}
value={value} value={value}
isAddButtonHidden isAddButtonHidden
item={Item} item={EnvironmentVariableItem}
errors={errors} errors={errors}
canUndoDelete={canUndoDelete}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => onChange([...value, { name: '', value: '' }])} onClick={() =>
onChange([...value, { name: '', value: '', needsDeletion: false }])
}
className="!ml-0"
color="default" color="default"
icon={Plus} icon={Plus}
> >
@ -66,54 +70,6 @@ export function SimpleMode({
); );
} }
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 }) { function FileEnv({ onChooseFile }: { onChooseFile: (file: Value) => void }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);

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

@ -1,4 +1,5 @@
import { ComponentProps, InputHTMLAttributes } from 'react'; import { ComponentProps, InputHTMLAttributes } from 'react';
import clsx from 'clsx';
import { InputGroup } from '../InputGroup'; import { InputGroup } from '../InputGroup';
@ -6,21 +7,29 @@ export function InputLabeled({
label, label,
className, className,
size, size,
needsDeletion,
id, id,
required,
disabled,
...props ...props
}: { }: {
label: string; label: string;
className?: string; className?: string;
size?: ComponentProps<typeof InputGroup>['size']; size?: ComponentProps<typeof InputGroup>['size'];
needsDeletion?: boolean;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'children'>) { } & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'children'>) {
return ( return (
<InputGroup className={className} size={size}> <InputGroup
<InputGroup.Addon as="label" htmlFor={id}> className={clsx(className, needsDeletion && 'striked')}
size={size}
>
<InputGroup.Addon as="label" htmlFor={id} required={required}>
{label} {label}
</InputGroup.Addon> </InputGroup.Addon>
<InputGroup.Input <InputGroup.Input
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
disabled={disabled || needsDeletion}
id={id} id={id}
/> />
</InputGroup> </InputGroup>

@ -12,7 +12,7 @@ const meta: Meta = {
export default meta; export default meta;
export { Defaults, ListWithInputAndSelect }; export { Defaults, ListWithInputAndSelect, ListWithUndoDeletion };
function Defaults() { function Defaults() {
const [values, setValues] = useState<DefaultType[]>([{ value: '' }]); const [values, setValues] = useState<DefaultType[]>([{ value: '' }]);
@ -26,6 +26,21 @@ function Defaults() {
); );
} }
function ListWithUndoDeletion() {
const [values, setValues] = useState<DefaultType[]>([
{ value: 'Existing item', needsDeletion: false },
]);
return (
<InputList
label="List with undo deletion"
value={values}
onChange={(value) => setValues(value)}
canUndoDelete
/>
);
}
interface ListWithSelectItem { interface ListWithSelectItem {
value: number; value: number;
select: string; select: string;

@ -1,6 +1,7 @@
import { ComponentType } from 'react'; import { ComponentType, useRef } from 'react';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Plus, RotateCw, Trash2 } from 'lucide-react';
import clsx from 'clsx';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
@ -9,7 +10,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { Input } from '../Input'; import { Input } from '../Input';
import { FormError } from '../FormError'; import { FormError } from '../FormError';
import { arrayMove } from './utils'; import { arrayMove, hasKey } from './utils';
type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[] type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
? ElementType ? ElementType
@ -30,10 +31,12 @@ export interface ItemProps<T> {
readOnly?: boolean; readOnly?: boolean;
// eslint-disable-next-line react/no-unused-prop-types // eslint-disable-next-line react/no-unused-prop-types
index: number; index: number;
needsDeletion?: boolean;
} }
type Key = string | number; type Key = string | number;
type ChangeType = 'delete' | 'create' | 'update'; type ChangeType = 'delete' | 'create' | 'update';
export type DefaultType = { value: string }; export type DefaultType = { value: string; needsDeletion?: boolean };
type CanUndoDeleteItem<T> = T & { needsDeletion: boolean };
type OnChangeEvent<T> = type OnChangeEvent<T> =
| { | {
@ -64,6 +67,7 @@ interface Props<T> {
addLabel?: string; addLabel?: string;
itemKeyGetter?(item: T, index: number): Key; itemKeyGetter?(item: T, index: number): Key;
movable?: boolean; movable?: boolean;
canUndoDelete?: boolean;
errors?: ArrayError<T[]>; errors?: ArrayError<T[]>;
textTip?: string; textTip?: string;
isAddButtonHidden?: boolean; isAddButtonHidden?: boolean;
@ -83,6 +87,7 @@ export function InputList<T = DefaultType>({
addLabel = 'Add item', addLabel = 'Add item',
itemKeyGetter = (item: T, index: number) => index, itemKeyGetter = (item: T, index: number) => index,
movable, movable,
canUndoDelete = false,
errors, errors,
textTip, textTip,
isAddButtonHidden = false, isAddButtonHidden = false,
@ -90,6 +95,7 @@ export function InputList<T = DefaultType>({
readOnly, readOnly,
'aria-label': ariaLabel, 'aria-label': ariaLabel,
}: Props<T>) { }: Props<T>) {
const initialItemsCount = useRef(value.length);
const isAddButtonVisible = !(isAddButtonHidden || readOnly); const isAddButtonVisible = !(isAddButtonHidden || readOnly);
return ( return (
<div className="form-group" aria-label={ariaLabel || label}> <div className="form-group" aria-label={ariaLabel || label}>
@ -154,7 +160,7 @@ export function InputList<T = DefaultType>({
/> />
</> </>
)} )}
{!readOnly && ( {!readOnly && !canUndoDelete && (
<Button <Button
color="dangerlight" color="dangerlight"
size="medium" size="medium"
@ -163,6 +169,17 @@ export function InputList<T = DefaultType>({
icon={Trash2} icon={Trash2}
/> />
)} )}
{!readOnly &&
canUndoDelete &&
hasKey(item, 'needsDeletion') && (
<CanUndoDeleteButton
item={{ ...item, needsDeletion: !!item.needsDeletion }}
itemIndex={index}
initialItemsCount={initialItemsCount.current}
handleRemoveItem={handleRemoveItem}
handleToggleNeedsDeletion={handleToggleNeedsDeletion}
/>
)}
</div> </div>
</div> </div>
); );
@ -224,6 +241,10 @@ export function InputList<T = DefaultType>({
); );
} }
function handleToggleNeedsDeletion(key: Key, item: CanUndoDeleteItem<T>) {
handleChangeItem(key, { ...item, needsDeletion: !item.needsDeletion });
}
function handleAdd() { function handleAdd() {
const newItem = itemBuilder(); const newItem = itemBuilder();
onChange([...value, newItem], { type: 'create', item: newItem }); onChange([...value, newItem], { type: 'create', item: newItem });
@ -260,8 +281,8 @@ function DefaultItem({
<Input <Input
value={item.value} value={item.value}
onChange={(e) => onChange({ value: e.target.value })} onChange={(e) => onChange({ value: e.target.value })}
className="!w-full" className={clsx('!w-full', item.needsDeletion && 'striked')}
disabled={disabled} disabled={disabled || item.needsDeletion}
readOnly={readOnly} readOnly={readOnly}
/> />
{error && <FormError>{error}</FormError>} {error && <FormError>{error}</FormError>}
@ -279,3 +300,51 @@ function renderDefaultItem(
<DefaultItem item={item} onChange={onChange} error={error} index={index} /> <DefaultItem item={item} onChange={onChange} error={error} index={index} />
); );
} }
type CanUndoDeleteButtonProps<T> = {
item: CanUndoDeleteItem<T>;
itemIndex: number;
initialItemsCount: number;
handleRemoveItem(key: Key, item: T): void;
handleToggleNeedsDeletion(key: Key, item: T): void;
};
function CanUndoDeleteButton<T>({
item,
itemIndex,
initialItemsCount,
handleRemoveItem,
handleToggleNeedsDeletion,
}: CanUndoDeleteButtonProps<T>) {
return (
<div className="items-start">
{!item.needsDeletion && (
<Button
color="dangerlight"
size="medium"
onClick={handleDeleteClick}
className="vertical-center btn-only-icon"
icon={Trash2}
/>
)}
{item.needsDeletion && (
<Button
color="default"
size="medium"
onClick={handleDeleteClick}
className="vertical-center btn-only-icon"
icon={RotateCw}
/>
)}
</div>
);
// if the item is new, we can just remove it, otherwise we need to toggle the needsDeletion flag
function handleDeleteClick() {
if (itemIndex < initialItemsCount) {
handleToggleNeedsDeletion(itemIndex, item);
} else {
handleRemoveItem(itemIndex, item);
}
}
}

@ -35,3 +35,14 @@ export function arrayMove<T>(array: Array<T>, from: number, to: number) {
return index >= 0 && index <= array.length; return index >= 0 && index <= array.length;
} }
} }
export function hasKey(
value: unknown,
key: string | number | symbol
): value is { needsDeletion: boolean } {
return isObject(value) && key in value;
}
function isObject(value: unknown): value is object {
return typeof value === 'object' && value !== null;
}

@ -0,0 +1,26 @@
import { SchemaOf, array, bool, object, string } from 'yup';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { buildUniquenessTest } from '@@/form-components/validate-unique';
export function kubeEnvVarValidationSchema(): SchemaOf<EnvVar[]> {
return array(
object({
name: string()
.required('Name is required')
.matches(
/^[a-zA-Z][a-zA-Z0-9_.-]*$/,
`This field must consist of alphabetic characters, digits, '_', '-', or '.', and must not start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.`
),
value: string().default(''),
needsDeletion: bool().default(false),
})
).test(
'unique',
'This environment variable is already defined.',
buildUniquenessTest(
() => 'This environment variable is already defined.',
'name'
)
);
}
Loading…
Cancel
Save