1
0
Fork 0
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:
Ali 2024-01-03 09:07:11 +13:00 committed by GitHub
parent 391b85da41
commit 7a2412b1be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 631 additions and 447 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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