mirror of
https://github.com/portainer/portainer.git
synced 2025-08-08 23:35:31 +02:00
refactor(templates): migrate list view to react [EE-2296] (#10999)
This commit is contained in:
parent
d38085a560
commit
6ff4fd3db2
103 changed files with 2628 additions and 1315 deletions
|
@ -1 +1,2 @@
|
|||
export { AccessControlForm } from './AccessControlForm';
|
||||
export { validationSchema as accessControlFormValidation } from './AccessControlForm.validation';
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { TemplateType } from './types';
|
||||
import { useAppTemplates } from './queries/useAppTemplates';
|
||||
import { AppTemplatesList } from './AppTemplatesList';
|
||||
import { DeployForm } from './DeployFormWidget/DeployFormWidget';
|
||||
|
||||
export function AppTemplatesView() {
|
||||
const envId = useEnvironmentId(false);
|
||||
|
||||
const hasCreateAuthQuery = useAuthorizations([
|
||||
'DockerContainerCreate',
|
||||
'PortainerStackCreate',
|
||||
]);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
|
||||
'template',
|
||||
(param) => (param ? parseInt(param, 10) : 0)
|
||||
);
|
||||
const templatesQuery = useAppTemplates();
|
||||
const selectedTemplate = selectedTemplateId
|
||||
? templatesQuery.data?.find(
|
||||
(template) => template.Id === selectedTemplateId
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { disabledTypes, fixedCategories, tableKey } = useViewFilter(envId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
||||
{selectedTemplate && (
|
||||
<DeployForm
|
||||
template={selectedTemplate}
|
||||
unselect={() => setSelectedTemplateId()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppTemplatesList
|
||||
templates={templatesQuery.data}
|
||||
selectedId={selectedTemplateId}
|
||||
onSelect={
|
||||
envId && hasCreateAuthQuery.authorized
|
||||
? (template) => setSelectedTemplateId(template.Id)
|
||||
: undefined
|
||||
}
|
||||
disabledTypes={disabledTypes}
|
||||
fixedCategories={fixedCategories}
|
||||
storageKey={tableKey}
|
||||
templateLinkParams={
|
||||
!envId
|
||||
? (template) => ({
|
||||
to: 'edge.stacks.new',
|
||||
params: { templateId: template.Id, templateType: 'app' },
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useViewFilter(envId: number | undefined) {
|
||||
const envInfoQuery = useInfo(envId);
|
||||
const apiVersion = useApiVersion(envId);
|
||||
|
||||
if (!envId) {
|
||||
// edge
|
||||
return {
|
||||
disabledTypes: [TemplateType.Container],
|
||||
fixedCategories: ['edge'],
|
||||
tableKey: 'edge-app-templates',
|
||||
};
|
||||
}
|
||||
|
||||
const showSwarmStacks =
|
||||
apiVersion >= 1.25 &&
|
||||
envInfoQuery.data &&
|
||||
envInfoQuery.data.Swarm &&
|
||||
envInfoQuery.data.Swarm.NodeID &&
|
||||
envInfoQuery.data.Swarm.ControlAvailable;
|
||||
|
||||
return {
|
||||
disabledTypes: !showSwarmStacks ? [TemplateType.SwarmStack] : [],
|
||||
tableKey: 'docker-app-templates',
|
||||
};
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { Minus, Plus } from 'lucide-react';
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
export function AdvancedSettings({
|
||||
children,
|
||||
label,
|
||||
}: PropsWithChildren<{
|
||||
label: (isOpen: boolean) => ReactNode;
|
||||
}>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<AdvancedSettingsToggle
|
||||
isOpen={isOpen}
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
label={label(isOpen)}
|
||||
/>
|
||||
|
||||
{isOpen ? children : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedSettingsToggle({
|
||||
label,
|
||||
onClick,
|
||||
isOpen,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
label: ReactNode;
|
||||
}) {
|
||||
const icon = isOpen ? Minus : Plus;
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<Button
|
||||
color="none"
|
||||
onClick={() => onClick()}
|
||||
data-cy="advanced-settings-toggle-button"
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
import { Formik, Form } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { NameField } from '@/react/docker/containers/CreateView/BaseForm/NameField';
|
||||
import { NetworkSelector } from '@/react/docker/containers/components/NetworkSelector';
|
||||
import { PortsMappingField } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
|
||||
import { VolumesTab } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||
import { HostsFileEntries } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
|
||||
import { LabelsTab } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||
import { HostnameField } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { TemplateViewModel } from '../../view-model';
|
||||
import { AdvancedSettings } from '../AdvancedSettings';
|
||||
import { EnvVarsFieldset } from '../EnvVarsFieldset';
|
||||
|
||||
import { useValidation } from './useValidation';
|
||||
import { FormValues } from './types';
|
||||
import { useCreate } from './useCreate';
|
||||
|
||||
export function ContainerDeployForm({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const validation = useValidation({
|
||||
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||
envVarDefinitions: template.Env,
|
||||
});
|
||||
|
||||
const createMutation = useCreate(template);
|
||||
|
||||
if (!createMutation || isEdgeAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: template.Name || '',
|
||||
envVars:
|
||||
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
||||
{},
|
||||
accessControl: parseAccessControlFormData(
|
||||
isEdgeAdminQuery.isAdmin,
|
||||
user.Id
|
||||
),
|
||||
hostname: '',
|
||||
hosts: [],
|
||||
labels: [],
|
||||
network: '',
|
||||
ports: template.Ports.map((p) => ({ ...p, hostPort: p.hostPort || '' })),
|
||||
volumes: template.Volumes.map((v) => ({
|
||||
containerPath: v.container,
|
||||
type: v.type === 'bind' ? 'bind' : 'volume',
|
||||
readOnly: v.readonly,
|
||||
name: v.type === 'bind' ? v.bind || '' : 'auto',
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={createMutation.onSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, setFieldValue, isValid }) => (
|
||||
<Form className="form-horizontal">
|
||||
<FormSection title="Configuration">
|
||||
<NameField
|
||||
value={values.name}
|
||||
onChange={(v) => setFieldValue('name', v)}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<FormControl label="Network" errors={errors?.network}>
|
||||
<NetworkSelector
|
||||
value={values.network}
|
||||
onChange={(v) => setFieldValue('network', v)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<EnvVarsFieldset
|
||||
values={values.envVars}
|
||||
onChange={(values) => setFieldValue('envVars', values)}
|
||||
errors={errors.envVars}
|
||||
options={template.Env || []}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
<AdvancedSettings
|
||||
label={(isOpen) =>
|
||||
isOpen ? 'Hide advanced options' : 'Show advanced options'
|
||||
}
|
||||
>
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(v) => setFieldValue('ports', v)}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<VolumesTab
|
||||
onChange={(v) => setFieldValue('volumes', v)}
|
||||
values={values.volumes}
|
||||
errors={errors.volumes}
|
||||
allowAuto
|
||||
/>
|
||||
|
||||
<HostsFileEntries
|
||||
values={values.hosts}
|
||||
onChange={(v) => setFieldValue('hosts', v)}
|
||||
errors={errors?.hosts}
|
||||
/>
|
||||
|
||||
<LabelsTab
|
||||
values={values.labels}
|
||||
onChange={(v) => setFieldValue('labels', v)}
|
||||
errors={errors?.labels}
|
||||
/>
|
||||
|
||||
<HostnameField
|
||||
value={values.hostname}
|
||||
onChange={(v) => setFieldValue('hostname', v)}
|
||||
error={errors.hostname}
|
||||
/>
|
||||
</AdvancedSettings>
|
||||
|
||||
<FormActions
|
||||
isLoading={createMutation.isLoading}
|
||||
isValid={isValid}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Deploy the container"
|
||||
data-cy="deploy-container-button"
|
||||
>
|
||||
<Button
|
||||
type="reset"
|
||||
onClick={() => unselect()}
|
||||
color="default"
|
||||
data-cy="cancel-deploy-container-button"
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||
import { parsePortBindingRequest } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel';
|
||||
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||
import { CreateContainerRequest } from '@/react/docker/containers/CreateView/types';
|
||||
|
||||
import { TemplateViewModel } from '../../view-model';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function createContainerConfiguration(
|
||||
template: TemplateViewModel,
|
||||
values: FormValues
|
||||
): CreateContainerRequest {
|
||||
let configuration: CreateContainerRequest = {
|
||||
Env: [],
|
||||
OpenStdin: false,
|
||||
Tty: false,
|
||||
ExposedPorts: {},
|
||||
HostConfig: {
|
||||
RestartPolicy: {
|
||||
Name: 'no',
|
||||
},
|
||||
PortBindings: {},
|
||||
Binds: [],
|
||||
Privileged: false,
|
||||
ExtraHosts: [],
|
||||
},
|
||||
Volumes: {},
|
||||
Labels: {},
|
||||
NetworkingConfig: {},
|
||||
};
|
||||
|
||||
configuration = volumesTabUtils.toRequest(configuration, values.volumes);
|
||||
|
||||
configuration.HostConfig.NetworkMode = values.network;
|
||||
configuration.HostConfig.Privileged = template.Privileged;
|
||||
configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
|
||||
configuration.HostConfig.ExtraHosts = values.hosts ? values.hosts : [];
|
||||
configuration.Hostname = values.hostname;
|
||||
configuration.Env = Object.entries(values.envVars).map(
|
||||
([name, value]) => `${name}=${value}`
|
||||
);
|
||||
configuration.Cmd = commandStringToArray(template.Command);
|
||||
const portBindings = parsePortBindingRequest(values.ports);
|
||||
configuration.HostConfig.PortBindings = portBindings;
|
||||
configuration.ExposedPorts = Object.fromEntries(
|
||||
Object.keys(portBindings).map((key) => [key, {}])
|
||||
);
|
||||
const consoleConfiguration = getConsoleConfiguration(template.Interactive);
|
||||
configuration.OpenStdin = consoleConfiguration.openStdin;
|
||||
configuration.Tty = consoleConfiguration.tty;
|
||||
configuration.Labels = Object.fromEntries(
|
||||
values.labels.filter((l) => !!l.name).map((l) => [l.name, l.value])
|
||||
);
|
||||
configuration.Image = template.RegistryModel.Image;
|
||||
return configuration;
|
||||
}
|
||||
|
||||
function getConsoleConfiguration(interactiveFlag: boolean) {
|
||||
return {
|
||||
openStdin: interactiveFlag,
|
||||
tty: interactiveFlag,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { PortMapping } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
|
||||
import { VolumesTabValues } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||
import { LabelsTabValues } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||
|
||||
import { EnvVarsValue } from '../EnvVarsFieldset';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
network: string;
|
||||
accessControl: AccessControlFormData;
|
||||
ports: Array<PortMapping>;
|
||||
volumes: VolumesTabValues;
|
||||
hosts: Array<string>;
|
||||
labels: LabelsTabValues;
|
||||
hostname: string;
|
||||
envVars: EnvVarsValue;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useCreateOrReplaceMutation } from '@/react/docker/containers/CreateView/useCreateMutation';
|
||||
|
||||
import { TemplateViewModel } from '../../view-model';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { createContainerConfiguration } from './createContainerConfig';
|
||||
import { useCreateLocalVolumes } from './useCreateLocalVolumes';
|
||||
|
||||
export function useCreate(template: TemplateViewModel) {
|
||||
const router = useRouter();
|
||||
const createVolumesMutation = useCreateLocalVolumes();
|
||||
const createContainerMutation = useCreateOrReplaceMutation();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
|
||||
if (!environmentQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = environmentQuery.data;
|
||||
|
||||
return {
|
||||
onSubmit,
|
||||
isLoading:
|
||||
createVolumesMutation.isLoading || createContainerMutation.isLoading,
|
||||
};
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
const autoVolumesCount = values.volumes.filter(
|
||||
(v) => v.type === 'volume' && v.name === 'auto'
|
||||
).length;
|
||||
createVolumesMutation.mutate(autoVolumesCount, {
|
||||
onSuccess(autoVolumes) {
|
||||
let index = 0;
|
||||
const volumes = values.volumes.map((v) =>
|
||||
v.type === 'volume' && v.name === 'auto'
|
||||
? { ...v, name: autoVolumes[index++].Name }
|
||||
: v
|
||||
);
|
||||
|
||||
createContainerMutation.mutate(
|
||||
{
|
||||
config: createContainerConfiguration(template, {
|
||||
...values,
|
||||
volumes,
|
||||
}),
|
||||
values: {
|
||||
name: values.name,
|
||||
accessControl: values.accessControl,
|
||||
imageName: template.RegistryModel.Image,
|
||||
alwaysPull: true,
|
||||
},
|
||||
environment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Container successfully created');
|
||||
router.stateService.go('docker.containers');
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { createVolume } from '@/react/docker/volumes/queries/useCreateVolume';
|
||||
|
||||
export function useCreateLocalVolumes() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
return useMutation(async (count: number) =>
|
||||
Promise.all(
|
||||
Array.from({ length: count }).map(() =>
|
||||
createVolume(environmentId, { Driver: 'local' })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { object, string } from 'yup';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { hostnameSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
|
||||
import { hostFileSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
|
||||
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||
import { nameValidation } from '@/react/docker/containers/CreateView/BaseForm/NameField';
|
||||
import { validationSchema as portSchema } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation';
|
||||
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||
|
||||
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
|
||||
import { TemplateEnv } from '../../types';
|
||||
|
||||
export function useValidation({
|
||||
isAdmin,
|
||||
envVarDefinitions,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
envVarDefinitions: Array<TemplateEnv>;
|
||||
}) {
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
||||
accessControl: accessControlFormValidation(isAdmin),
|
||||
envVars: envVarsFieldsetValidation(envVarDefinitions),
|
||||
hostname: hostnameSchema,
|
||||
hosts: hostFileSchema,
|
||||
labels: labelsTabUtils.validation(),
|
||||
name: nameValidation(),
|
||||
network: string().default(''),
|
||||
ports: portSchema(),
|
||||
volumes: volumesTabUtils.validation(),
|
||||
}),
|
||||
[envVarDefinitions, isAdmin]
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
|
||||
|
||||
import { ContainerDeployForm } from './ContainerDeployForm/ContainerDeployForm';
|
||||
import { StackDeployForm } from './StackDeployForm/StackDeployForm';
|
||||
|
||||
export function DeployForm({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
const Form = useForm(template);
|
||||
|
||||
return (
|
||||
<DeployWidget
|
||||
logo={template.Logo}
|
||||
note={template.Note}
|
||||
title={template.Title}
|
||||
>
|
||||
<Form template={template} unselect={unselect} />
|
||||
</DeployWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function useForm(template: TemplateViewModel) {
|
||||
const envId = useEnvironmentId(false);
|
||||
|
||||
if (!envId) {
|
||||
// for edge templates, return empty form
|
||||
return () => null;
|
||||
}
|
||||
|
||||
if (template.Type === TemplateType.Container) {
|
||||
return ContainerDeployForm;
|
||||
}
|
||||
|
||||
return StackDeployForm;
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import { vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
EnvVarsFieldset,
|
||||
getDefaultValues,
|
||||
envVarsFieldsetValidation,
|
||||
} from './EnvVarsFieldset';
|
||||
|
||||
test('renders EnvVarsFieldset component', () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ name: 'VAR1', label: 'Variable 1', preset: false },
|
||||
{ name: 'VAR2', label: 'Variable 2', preset: false },
|
||||
] as const;
|
||||
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={[...options]}
|
||||
values={value}
|
||||
errors={{}}
|
||||
/>
|
||||
);
|
||||
|
||||
options.forEach((option) => {
|
||||
const labelElement = screen.getByLabelText(option.label, { exact: false });
|
||||
expect(labelElement).toBeInTheDocument();
|
||||
|
||||
const inputElement = screen.getByDisplayValue(value[option.name]);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('calls onChange when input value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||
const value = { VAR1: 'Value 1' };
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
values={value}
|
||||
errors={{}}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputElement = screen.getByDisplayValue(value.VAR1);
|
||||
await user.clear(inputElement);
|
||||
expect(onChange).toHaveBeenCalledWith({ VAR1: '' });
|
||||
|
||||
const newValue = 'New Value';
|
||||
await user.type(inputElement, newValue);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders error message when there are errors', () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
|
||||
const value = { VAR1: '' };
|
||||
|
||||
render(
|
||||
<EnvVarsFieldset
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
values={value}
|
||||
errors={{ VAR1: 'Required' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorElement = screen.getByText('Required');
|
||||
expect(errorElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('returns default values', () => {
|
||||
const definitions = [
|
||||
{
|
||||
name: 'VAR1',
|
||||
label: 'Variable 1',
|
||||
preset: false,
|
||||
default: 'Default Value 1',
|
||||
},
|
||||
{
|
||||
name: 'VAR2',
|
||||
label: 'Variable 2',
|
||||
preset: false,
|
||||
default: 'Default Value 2',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues = getDefaultValues(definitions);
|
||||
|
||||
expect(defaultValues).toEqual({
|
||||
VAR1: 'Default Value 1',
|
||||
VAR2: 'Default Value 2',
|
||||
});
|
||||
});
|
||||
|
||||
test('validates env vars fieldset', () => {
|
||||
const schema = envVarsFieldsetValidation([
|
||||
{ name: 'VAR1' },
|
||||
{ name: 'VAR2' },
|
||||
]);
|
||||
|
||||
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
|
||||
const invalidData = { VAR1: '', VAR2: 'Value 2' };
|
||||
|
||||
const validResult = schema.isValidSync(validData);
|
||||
const invalidResult = schema.isValidSync(invalidData);
|
||||
|
||||
expect(validResult).toBe(true);
|
||||
expect(invalidResult).toBe(false);
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { SchemaOf, object, string } from 'yup';
|
||||
|
||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
|
||||
type Value = Record<string, string>;
|
||||
|
||||
export { type Value as EnvVarsValue };
|
||||
|
||||
export function EnvVarsFieldset({
|
||||
onChange,
|
||||
options,
|
||||
values,
|
||||
errors,
|
||||
}: {
|
||||
options: Array<TemplateEnv>;
|
||||
onChange: (value: Value) => void;
|
||||
values: Value;
|
||||
errors?: FormikErrors<Value>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{options.map((env) => (
|
||||
<Item
|
||||
key={env.name}
|
||||
option={env}
|
||||
value={values[env.name]}
|
||||
onChange={(value) => handleChange(env.name, value)}
|
||||
errors={errors?.[env.name]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(name: string, envValue: string) {
|
||||
onChange({ ...values, [name]: envValue });
|
||||
}
|
||||
}
|
||||
|
||||
function Item({
|
||||
onChange,
|
||||
option,
|
||||
value,
|
||||
errors,
|
||||
}: {
|
||||
option: TemplateEnv;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
errors?: FormikErrors<string>;
|
||||
}) {
|
||||
const inputId = `env_var_${option.name}`;
|
||||
return (
|
||||
<FormControl
|
||||
label={option.label || option.name}
|
||||
required={!option.preset}
|
||||
errors={errors}
|
||||
inputId={inputId}
|
||||
>
|
||||
{option.select ? (
|
||||
<Select
|
||||
value={value}
|
||||
data-cy={`env-var-select-${option.name}`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
options={option.select.map((o) => ({
|
||||
label: o.text,
|
||||
value: o.value,
|
||||
}))}
|
||||
disabled={option.preset}
|
||||
id={inputId}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={option.preset}
|
||||
id={inputId}
|
||||
data-cy="env-var-input"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
|
||||
return Object.fromEntries(
|
||||
definitions.map((v) => {
|
||||
if (v.select) {
|
||||
return [v.name, v.select.find((v) => v.default)?.value || ''];
|
||||
}
|
||||
|
||||
return [v.name, v.default || ''];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function envVarsFieldsetValidation(
|
||||
definitions: Array<TemplateEnv>
|
||||
): SchemaOf<Value> {
|
||||
return object(
|
||||
Object.fromEntries(
|
||||
definitions.map((v) => [v.name, string().required('Required')])
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { Formik, Form } from 'formik';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
SwarmCreatePayload,
|
||||
useCreateStack,
|
||||
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { NameField } from '@/react/common/stacks/CreateView/NameField';
|
||||
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { EnvVarsFieldset } from '../EnvVarsFieldset';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './useValidation';
|
||||
import { useIsDeployable } from './useIsDeployable';
|
||||
|
||||
export function StackDeployForm({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
const isDeployable = useIsDeployable(template.Type);
|
||||
|
||||
const router = useRouter();
|
||||
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const environmentId = useEnvironmentId();
|
||||
const swarmIdQuery = useSwarmId(environmentId);
|
||||
const mutation = useCreateStack();
|
||||
const validation = useValidation({
|
||||
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||
environmentId,
|
||||
envVarDefinitions: template.Env,
|
||||
});
|
||||
|
||||
if (isEdgeAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: template.Name || '',
|
||||
envVars:
|
||||
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
||||
{},
|
||||
accessControl: parseAccessControlFormData(
|
||||
isEdgeAdminQuery.isAdmin,
|
||||
user.Id
|
||||
),
|
||||
};
|
||||
|
||||
if (!isDeployable) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<TextTip>
|
||||
This template type cannot be deployed on this environment.
|
||||
</TextTip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, setFieldValue, isValid }) => (
|
||||
<Form className="form-horizontal">
|
||||
<FormSection title="Configuration">
|
||||
<NameField
|
||||
value={values.name}
|
||||
onChange={(v) => setFieldValue('name', v)}
|
||||
errors={errors.name}
|
||||
/>
|
||||
|
||||
<EnvVarsFieldset
|
||||
values={values.envVars}
|
||||
onChange={(values) => setFieldValue('envVars', values)}
|
||||
errors={errors.envVars}
|
||||
options={template.Env || []}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
<FormActions
|
||||
isLoading={mutation.isLoading}
|
||||
isValid={isValid}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Deploy the stack"
|
||||
data-cy="deploy-stack-button"
|
||||
>
|
||||
<Button
|
||||
type="reset"
|
||||
onClick={() => unselect()}
|
||||
color="default"
|
||||
data-cy="cancel-deploy-stack-button"
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
const type =
|
||||
template.Type === TemplateType.ComposeStack ? 'standalone' : 'swarm';
|
||||
const payload: SwarmCreatePayload['payload'] = {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
|
||||
env: Object.entries(values.envVars).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
})),
|
||||
swarmId: swarmIdQuery.data || '',
|
||||
git: {
|
||||
RepositoryURL: template.Repository.url,
|
||||
ComposeFilePathInRepository: template.Repository.stackfile,
|
||||
},
|
||||
fromAppTemplate: true,
|
||||
accessControl: values.accessControl,
|
||||
};
|
||||
|
||||
return mutation.mutate(
|
||||
{
|
||||
type,
|
||||
method: 'git',
|
||||
payload,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Stack created');
|
||||
router.stateService.go('docker.stacks');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
envVars: Record<string, string>;
|
||||
accessControl: AccessControlFormData;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
|
||||
|
||||
export function useIsDeployable(type: TemplateType) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const isSwarm = useIsSwarm(environmentId);
|
||||
|
||||
switch (type) {
|
||||
case TemplateType.ComposeStack:
|
||||
case TemplateType.Container:
|
||||
return true;
|
||||
case TemplateType.SwarmStack:
|
||||
return isSwarm;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SchemaOf, object } from 'yup';
|
||||
|
||||
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
|
||||
import { TemplateEnv } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function useValidation({
|
||||
environmentId,
|
||||
isAdmin,
|
||||
envVarDefinitions,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
environmentId: EnvironmentId;
|
||||
envVarDefinitions: Array<TemplateEnv>;
|
||||
}): SchemaOf<FormValues> {
|
||||
const name = useNameValidation(environmentId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
||||
name,
|
||||
accessControl: accessControlFormValidation(isAdmin),
|
||||
envVars: envVarsFieldsetValidation(envVarDefinitions),
|
||||
}),
|
||||
[envVarDefinitions, isAdmin, name]
|
||||
);
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { RestartPolicy } from 'docker-types/generated/1.41';
|
||||
|
||||
import { BasicTableSettings } from '@@/datatables/types';
|
||||
|
||||
import { Pair } from '../../settings/types';
|
||||
|
@ -152,7 +154,7 @@ export interface AppTemplate {
|
|||
* Container restart policy.
|
||||
* @example "on-failure"
|
||||
*/
|
||||
restart_policy?: string;
|
||||
restart_policy?: RestartPolicy['Name'];
|
||||
|
||||
/**
|
||||
* Container hostname.
|
||||
|
@ -181,7 +183,7 @@ export interface TemplateRepository {
|
|||
/**
|
||||
* TemplateVolume represents a template volume configuration.
|
||||
*/
|
||||
interface TemplateVolume {
|
||||
export interface TemplateVolume {
|
||||
/**
|
||||
* Path inside the container.
|
||||
* @example "/data"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
import { RestartPolicy } from 'docker-types/generated/1.41';
|
||||
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
|
@ -47,7 +48,7 @@ export class TemplateViewModel {
|
|||
|
||||
Interactive!: boolean;
|
||||
|
||||
RestartPolicy!: string;
|
||||
RestartPolicy!: RestartPolicy['Name'];
|
||||
|
||||
Hosts!: string[];
|
||||
|
||||
|
@ -58,14 +59,14 @@ export class TemplateViewModel {
|
|||
Volumes!: {
|
||||
container: string;
|
||||
readonly: boolean;
|
||||
type: string;
|
||||
type: 'bind' | 'auto';
|
||||
bind: string | null;
|
||||
}[];
|
||||
|
||||
Ports!: {
|
||||
hostPort: string | undefined;
|
||||
containerPort: string;
|
||||
protocol: string;
|
||||
protocol: 'tcp' | 'udp';
|
||||
}[];
|
||||
|
||||
constructor(template: AppTemplate, version: string) {
|
||||
|
@ -134,7 +135,7 @@ function templatePorts(data: AppTemplate) {
|
|||
hostAndContainerPort.length > 1
|
||||
? hostAndContainerPort[1]
|
||||
: hostAndContainerPort[0],
|
||||
protocol: portAndProtocol[1],
|
||||
protocol: portAndProtocol[1] as 'tcp' | 'udp',
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
|
@ -145,7 +146,7 @@ function templateVolumes(data: AppTemplate) {
|
|||
data.volumes?.map((v) => ({
|
||||
container: v.container,
|
||||
readonly: v.readonly || false,
|
||||
type: v.bind ? 'bind' : 'auto',
|
||||
type: (v.bind ? 'bind' : 'auto') as 'bind' | 'auto',
|
||||
bind: v.bind ? v.bind : null,
|
||||
})) || []
|
||||
);
|
||||
|
|
40
app/react/portainer/templates/components/DeployWidget.tsx
Normal file
40
app/react/portainer/templates/components/DeployWidget.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Rocket } from 'lucide-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { FallbackImage } from '@@/FallbackImage';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { TemplateNote } from './TemplateNote';
|
||||
|
||||
export function DeployWidget({
|
||||
logo,
|
||||
note,
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
logo?: string;
|
||||
note?: string;
|
||||
title: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Title
|
||||
icon={
|
||||
<FallbackImage src={logo} fallbackIcon={<Icon icon={Rocket} />} />
|
||||
}
|
||||
title={title}
|
||||
/>
|
||||
<Widget.Body>
|
||||
<div className="form-horizontal">
|
||||
<TemplateNote note={note} />
|
||||
{children}
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
25
app/react/portainer/templates/components/TemplateNote.tsx
Normal file
25
app/react/portainer/templates/components/TemplateNote.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import sanitize from 'sanitize-html';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
export function TemplateNote({ note }: { note?: string }) {
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Information">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<div
|
||||
className="text-xs"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(note),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -17,9 +17,11 @@ import { InnerForm } from './InnerForm';
|
|||
export function CreateForm({
|
||||
environmentId,
|
||||
viewType,
|
||||
defaultType,
|
||||
}: {
|
||||
environmentId?: EnvironmentId;
|
||||
viewType: 'kube' | 'docker' | 'edge';
|
||||
defaultType: StackType;
|
||||
}) {
|
||||
const isEdge = !environmentId;
|
||||
const router = useRouter();
|
||||
|
@ -28,8 +30,7 @@ export function CreateForm({
|
|||
const buildMethods = useBuildMethods();
|
||||
|
||||
const initialValues = useInitialValues({
|
||||
defaultType:
|
||||
viewType === 'kube' ? StackType.Kubernetes : StackType.DockerCompose,
|
||||
defaultType,
|
||||
isEdge,
|
||||
buildMethods: buildMethods.map((method) => method.value),
|
||||
});
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { useViewType } from '../useViewType';
|
||||
import { TemplateViewType, useViewType } from '../useViewType';
|
||||
|
||||
import { CreateForm } from './CreateForm';
|
||||
|
||||
export function CreateView() {
|
||||
const viewType = useViewType();
|
||||
const environmentId = useEnvironmentId(false);
|
||||
const isSwarm = useIsSwarm(environmentId, { enabled: viewType === 'docker' });
|
||||
const defaultType = getDefaultType(viewType, isSwarm);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -25,7 +29,11 @@ export function CreateView() {
|
|||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<CreateForm viewType={viewType} environmentId={environmentId} />
|
||||
<CreateForm
|
||||
viewType={viewType}
|
||||
environmentId={environmentId}
|
||||
defaultType={defaultType}
|
||||
/>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
|
@ -33,3 +41,17 @@ export function CreateView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultType(
|
||||
viewType: TemplateViewType,
|
||||
isSwarm: boolean
|
||||
): StackType {
|
||||
switch (viewType) {
|
||||
case 'docker':
|
||||
return isSwarm ? StackType.DockerSwarm : StackType.DockerCompose;
|
||||
case 'kube':
|
||||
return StackType.Kubernetes;
|
||||
default:
|
||||
return StackType.DockerCompose;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export function useCustomTemplates<T = Array<CustomTemplate>>({
|
|||
});
|
||||
}
|
||||
|
||||
async function getCustomTemplates({ type, edge = false }: Params = {}) {
|
||||
async function getCustomTemplates({ type, edge }: Params = {}) {
|
||||
try {
|
||||
const { data } = await axios.get<CustomTemplate[]>(buildUrl(), {
|
||||
params: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue