mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
refactor(app): migrate configmap and secret form sections [EE-6233] (#10528)
* refactor(app): migrate configmap and secret form sections [EE-6233]
This commit is contained in:
parent
391b85da41
commit
7a2412b1be
18 changed files with 631 additions and 447 deletions
|
@ -0,0 +1,82 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useConfigMaps } from '@/react/kubernetes/configs/configmap.service';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
||||
import { ConfigurationItem } from './ConfigurationItem';
|
||||
import { ConfigurationFormValues } from './types';
|
||||
|
||||
type Props = {
|
||||
values: ConfigurationFormValues[];
|
||||
onChange: (values: ConfigurationFormValues[]) => void;
|
||||
errors: FormikErrors<ConfigurationFormValues[]>;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
export function ConfigMapsFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
namespace,
|
||||
}: Props) {
|
||||
const configMapsQuery = useConfigMaps(useEnvironmentId(), namespace);
|
||||
const configMaps = configMapsQuery.data || [];
|
||||
|
||||
if (configMapsQuery.isLoading) {
|
||||
return <InlineLoader>Loading ConfigMaps...</InlineLoader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="ConfigMaps" titleSize="sm">
|
||||
{!!values.length && (
|
||||
<TextTip color="blue">
|
||||
Portainer will automatically expose all the keys of a ConfigMap as
|
||||
environment variables. This behavior can be overridden to filesystem
|
||||
mounts for each key via the override option.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<InputList<ConfigurationFormValues>
|
||||
value={values}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
isDeleteButtonHidden
|
||||
deleteButtonDataCy="k8sAppCreate-configRemoveButton"
|
||||
addButtonDataCy="k8sAppCreate-configAddButton"
|
||||
disabled={configMaps.length === 0}
|
||||
addButtonError={
|
||||
configMaps.length === 0
|
||||
? 'There are no ConfigMaps available in this namespace.'
|
||||
: undefined
|
||||
}
|
||||
renderItem={(item, onChange, index, error) => (
|
||||
<ConfigurationItem
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
configurations={configMaps}
|
||||
onRemoveItem={() => onRemoveItem(index)}
|
||||
index={index}
|
||||
dataCyType="config"
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
selectedConfigMap: configMaps[0]?.metadata?.name || '',
|
||||
overriden: false,
|
||||
overridenKeys: [],
|
||||
selectedConfiguration: configMaps[0],
|
||||
})}
|
||||
addLabel="Add ConfigMap"
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
function onRemoveItem(index: number) {
|
||||
onChange(values.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
import clsx from 'clsx';
|
||||
import { List, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { ConfigMap, Secret } from 'kubernetes-types/core/v1';
|
||||
import { SingleValue } from 'react-select';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ItemError } from '@@/form-components/InputList/InputList';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
import { Button } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ConfigurationFormValues, ConfigurationOverrideKey } from './types';
|
||||
import { ConfigurationData } from './ConfigurationKey';
|
||||
|
||||
type Props = {
|
||||
item: ConfigurationFormValues;
|
||||
onChange: (values: ConfigurationFormValues) => void;
|
||||
onRemoveItem: () => void;
|
||||
configurations: Array<ConfigMap | Secret>;
|
||||
index: number;
|
||||
error?: ItemError<ConfigurationFormValues>;
|
||||
dataCyType: 'config' | 'secret';
|
||||
};
|
||||
|
||||
export function ConfigurationItem({
|
||||
item,
|
||||
onChange,
|
||||
error,
|
||||
configurations,
|
||||
index,
|
||||
onRemoveItem,
|
||||
dataCyType,
|
||||
}: Props) {
|
||||
// rule out the error being of type string
|
||||
const formikError = isErrorType(error) ? error : undefined;
|
||||
const configurationData = item.selectedConfiguration.data || {};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex items-start gap-x-2 gap-y-2">
|
||||
<div>
|
||||
<InputGroup size="small" className="min-w-[250px]">
|
||||
<InputGroup.Addon>Name</InputGroup.Addon>
|
||||
<Select
|
||||
options={configurations}
|
||||
isMulti={false}
|
||||
getOptionLabel={(option) => option.metadata?.name || ''}
|
||||
noOptionsMessage={() => 'No ConfigMaps found.'}
|
||||
value={configurations.find(
|
||||
(configuration) =>
|
||||
configuration.metadata?.name ===
|
||||
item.selectedConfiguration.metadata?.name
|
||||
)}
|
||||
onChange={onSelectConfigMap}
|
||||
size="sm"
|
||||
data-cy={`k8sAppCreate-add${dataCyType}Select_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{formikError?.selectedConfiguration && (
|
||||
<FormError>{formikError?.selectedConfiguration}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<InputGroup size="small">
|
||||
<InputGroup.ButtonWrapper>
|
||||
<Button
|
||||
color="light"
|
||||
size="medium"
|
||||
className={clsx('!ml-0', { active: !item.overriden })}
|
||||
onClick={() => onToggleOverride(false)}
|
||||
icon={RotateCw}
|
||||
>
|
||||
Auto
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
size="medium"
|
||||
className={clsx('!ml-0 mr-1', { active: item.overriden })}
|
||||
onClick={() => onToggleOverride(true)}
|
||||
icon={List}
|
||||
>
|
||||
Override
|
||||
</Button>
|
||||
</InputGroup.ButtonWrapper>
|
||||
</InputGroup>
|
||||
<Button
|
||||
color="dangerlight"
|
||||
size="medium"
|
||||
onClick={() => onRemoveItem()}
|
||||
className="!ml-0 vertical-center btn-only-icon"
|
||||
icon={Trash2}
|
||||
/>
|
||||
</div>
|
||||
{!item.overriden && (
|
||||
<TextTip color="blue">
|
||||
The following keys will be loaded from the{' '}
|
||||
<code>{item.selectedConfiguration.metadata?.name}</code>
|
||||
ConfigMap as environment variables:
|
||||
{Object.keys(configurationData).map((key, index) => (
|
||||
<span key={key}>
|
||||
<code>{key}</code>
|
||||
{index < Object.keys(configurationData).length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</TextTip>
|
||||
)}
|
||||
{item.overriden &&
|
||||
item.overridenKeys.map((overridenKey, keyIndex) => (
|
||||
<ConfigurationData
|
||||
key={overridenKey.key}
|
||||
value={overridenKey}
|
||||
onChange={(value: ConfigurationOverrideKey) => {
|
||||
const newOverridenKeys = [...item.overridenKeys];
|
||||
newOverridenKeys[keyIndex] = value;
|
||||
onChange({ ...item, overridenKeys: newOverridenKeys });
|
||||
}}
|
||||
overrideKeysErrors={formikError?.overridenKeys}
|
||||
dataCyType={dataCyType}
|
||||
configurationIndex={index}
|
||||
keyIndex={keyIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function onSelectConfigMap(configMap: SingleValue<ConfigMap | Secret>) {
|
||||
if (configMap) {
|
||||
onChange({
|
||||
...item,
|
||||
overriden: false,
|
||||
selectedConfiguration: configMap,
|
||||
overridenKeys: Object.keys(configMap.data || {}).map((key) => ({
|
||||
key,
|
||||
path: '',
|
||||
type: 'NONE',
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleOverride(overriden: boolean) {
|
||||
onChange({
|
||||
...item,
|
||||
overriden,
|
||||
overridenKeys: Object.keys(configurationData).map((key) => ({
|
||||
key,
|
||||
path: '',
|
||||
type: overriden ? 'ENVIRONMENT' : 'NONE',
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import clsx from 'clsx';
|
||||
import { RotateCw, List } from 'lucide-react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { isArrayErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { ConfigurationOverrideKey } from './types';
|
||||
|
||||
type Props = {
|
||||
value: ConfigurationOverrideKey;
|
||||
onChange: (value: ConfigurationOverrideKey) => void;
|
||||
configurationIndex: number;
|
||||
keyIndex: number;
|
||||
overrideKeysErrors?:
|
||||
| string
|
||||
| string[]
|
||||
| FormikErrors<ConfigurationOverrideKey>[];
|
||||
dataCyType: 'config' | 'secret';
|
||||
};
|
||||
|
||||
export function ConfigurationData({
|
||||
value,
|
||||
onChange,
|
||||
overrideKeysErrors,
|
||||
configurationIndex,
|
||||
keyIndex,
|
||||
dataCyType,
|
||||
}: Props) {
|
||||
// rule out the error (from formik) being of type string
|
||||
const overriddenKeyError = isArrayErrorType(overrideKeysErrors)
|
||||
? overrideKeysErrors[keyIndex]
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex items-start gap-x-2 gap-y-2 flex-wrap">
|
||||
<InputGroup size="small" className="min-w-[250px]">
|
||||
<InputGroup.Addon>Key</InputGroup.Addon>
|
||||
<InputGroup.Input type="text" value={value.key} disabled />
|
||||
</InputGroup>
|
||||
<InputGroup size="small">
|
||||
<InputGroup.ButtonWrapper>
|
||||
<Button
|
||||
color="light"
|
||||
size="medium"
|
||||
className={clsx('!ml-0', { active: value.type === 'ENVIRONMENT' })}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
path: '',
|
||||
type: 'ENVIRONMENT',
|
||||
})
|
||||
}
|
||||
icon={RotateCw}
|
||||
data-cy={`k8sAppCreate-${dataCyType}AutoButton_${configurationIndex}_${keyIndex}`}
|
||||
>
|
||||
Environment
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
size="medium"
|
||||
className={clsx('!ml-0 mr-1', {
|
||||
active: value.type === 'FILESYSTEM',
|
||||
})}
|
||||
onClick={() => onChange({ ...value, path: '', type: 'FILESYSTEM' })}
|
||||
icon={List}
|
||||
data-cy={`k8sAppCreate-${dataCyType}OverrideButton_${configurationIndex}_${keyIndex}`}
|
||||
>
|
||||
File system
|
||||
</Button>
|
||||
</InputGroup.ButtonWrapper>
|
||||
</InputGroup>
|
||||
|
||||
{value.type === 'FILESYSTEM' && (
|
||||
<div>
|
||||
<InputGroup size="small" className="min-w-[250px]">
|
||||
<InputGroup.Addon required>Path on disk</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="text"
|
||||
value={value.path}
|
||||
placeholder="e.g. /etc/myapp/conf.d"
|
||||
onChange={(e) => onChange({ ...value, path: e.target.value })}
|
||||
data-cy={`k8sAppCreate-${dataCyType}PathOnDiskInput_${configurationIndex}_${keyIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{overriddenKeyError?.path && (
|
||||
<FormError>{overriddenKeyError.path}</FormError>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
||||
import { ConfigurationItem } from './ConfigurationItem';
|
||||
import { ConfigurationFormValues } from './types';
|
||||
|
||||
type Props = {
|
||||
values: ConfigurationFormValues[];
|
||||
onChange: (values: ConfigurationFormValues[]) => void;
|
||||
errors: FormikErrors<ConfigurationFormValues[]>;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
export function SecretsFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
namespace,
|
||||
}: Props) {
|
||||
const secretsQuery = useSecrets(useEnvironmentId(), namespace);
|
||||
const secrets = secretsQuery.data || [];
|
||||
|
||||
if (secretsQuery.isLoading) {
|
||||
return <InlineLoader>Loading Secrets...</InlineLoader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Secrets" titleSize="sm">
|
||||
{!!values.length && (
|
||||
<TextTip color="blue">
|
||||
Portainer will automatically expose all the keys of a Secret as
|
||||
environment variables. This behavior can be overridden to filesystem
|
||||
mounts for each key via the override option.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<InputList<ConfigurationFormValues>
|
||||
value={values}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
isDeleteButtonHidden
|
||||
deleteButtonDataCy="k8sAppCreate-secretRemoveButton"
|
||||
addButtonDataCy="k8sAppCreate-secretAddButton"
|
||||
disabled={secrets.length === 0}
|
||||
addButtonError={
|
||||
secrets.length === 0
|
||||
? 'There are no Secrets available in this namespace.'
|
||||
: undefined
|
||||
}
|
||||
renderItem={(item, onChange, index, error) => (
|
||||
<ConfigurationItem
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
configurations={secrets}
|
||||
onRemoveItem={() => onRemoveItem(index)}
|
||||
index={index}
|
||||
dataCyType="secret"
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
selectedConfigMap: secrets[0]?.metadata?.name || '',
|
||||
overriden: false,
|
||||
overridenKeys: [],
|
||||
selectedConfiguration: secrets[0],
|
||||
})}
|
||||
addLabel="Add Secret"
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
function onRemoveItem(index: number) {
|
||||
onChange(values.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { SchemaOf, array, boolean, mixed, object, string } from 'yup';
|
||||
|
||||
import { buildUniquenessTest } from '@@/form-components/validate-unique';
|
||||
|
||||
import { ConfigurationFormValues } from './types';
|
||||
|
||||
export function configurationsValidationSchema(
|
||||
validationData?: ConfigurationFormValues[]
|
||||
): SchemaOf<ConfigurationFormValues[]> {
|
||||
return array(
|
||||
object({
|
||||
overriden: boolean().required(),
|
||||
// skip validation for selectedConfiguration because it comes directly from a select dropdown
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
selectedConfiguration: object({} as any).required(),
|
||||
overridenKeys: array(
|
||||
object({
|
||||
key: string().required(),
|
||||
path: string().when('type', {
|
||||
is: 'FILESYSTEM',
|
||||
then: string()
|
||||
.test(
|
||||
'No duplicates globally',
|
||||
'This path is already used.',
|
||||
(path?: string) => {
|
||||
const allPaths = validationData
|
||||
?.flatMap((configmap) => configmap.overridenKeys)
|
||||
.map((k) => k.path);
|
||||
if (!allPaths) return true;
|
||||
return (
|
||||
allPaths.filter((p) => p === path && p !== '').length <= 1
|
||||
);
|
||||
}
|
||||
)
|
||||
.required('Path is required.'),
|
||||
}),
|
||||
type: mixed().oneOf(['NONE', 'ENVIRONMENT', 'FILESYSTEM']),
|
||||
})
|
||||
)
|
||||
.test(
|
||||
'No duplicates',
|
||||
'This path is already used.',
|
||||
buildUniquenessTest(() => 'This path is already used.', 'path')
|
||||
)
|
||||
.required(),
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { ConfigMap, Secret } from 'kubernetes-types/core/v1';
|
||||
|
||||
export type ConfigurationFormValues = {
|
||||
overriden: boolean;
|
||||
overridenKeys: ConfigurationOverrideKey[];
|
||||
selectedConfiguration: ConfigMap | Secret;
|
||||
};
|
||||
|
||||
export type ConfigurationOverrideKey = {
|
||||
key: string;
|
||||
type: ConfigurationOverrideKeyType;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
type ConfigurationOverrideKeyType = 'NONE' | 'ENVIRONMENT' | 'FILESYSTEM';
|
Loading…
Add table
Add a link
Reference in a new issue