1
0
Fork 0
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:
Chaim Lev-Ari 2024-04-11 09:29:30 +03:00 committed by GitHub
parent d38085a560
commit 6ff4fd3db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 2628 additions and 1315 deletions

View file

@ -1 +1,2 @@
export { AccessControlForm } from './AccessControlForm';
export { validationSchema as accessControlFormValidation } from './AccessControlForm.validation';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
import { AccessControlFormData } from '@/react/portainer/access-control/types';
export interface FormValues {
name: string;
envVars: Record<string, string>;
accessControl: AccessControlFormData;
}

View file

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

View file

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

View file

@ -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"

View file

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

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

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

View file

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

View file

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

View file

@ -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: {