mirror of https://github.com/portainer/portainer
refactor(app): migrate env var form section [EE-6232] (#10499)
* refactor(app): migrate env var form section [EE-6232] * allow undoing delete in inputlistpull/10528/head
parent
6228314e3c
commit
488393007f
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="col-sm-12">
|
||||||
<EnvironmentVariablesFieldset
|
<EnvironmentVariablesFieldset
|
||||||
values={values}
|
values={values}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
errors={errors}
|
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…
Reference in New Issue