1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

feat(edge/stacks): add app templates to deploy types [EE-6632] (#11040)

This commit is contained in:
Chaim Lev-Ari 2024-02-15 09:01:01 +02:00 committed by GitHub
parent 31f5b42962
commit 437831fa80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1293 additions and 482 deletions

View file

@ -1,149 +0,0 @@
import { SetStateAction, useEffect, useState } from 'react';
import sanitize from 'sanitize-html';
import { FormikErrors } from 'formik';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
CustomTemplatesVariablesField,
VariablesFieldValue,
getVariablesFieldDefaultValues,
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { FormControl } from '@@/form-components/FormControl';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
export interface Values {
template: CustomTemplate | undefined;
variables: VariablesFieldValue;
}
export function TemplateFieldset({
values: initialValues,
setValues: setInitialValues,
errors,
}: {
errors?: FormikErrors<Values>;
values: Values;
setValues: (values: SetStateAction<Values>) => void;
}) {
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
useEffect(() => {
if (initialValues.template?.Id !== values.template?.Id) {
setControlledValues(initialValues);
}
}, [initialValues, values.template?.Id]);
const templatesQuery = useCustomTemplates({
params: {
edge: true,
},
});
return (
<>
<TemplateSelector
error={errors?.template}
value={values.template?.Id}
onChange={(value) => {
setValues((values) => {
const template = templatesQuery.data?.find(
(template) => template.Id === value
);
return {
...values,
template,
variables: getVariablesFieldDefaultValues(
template?.Variables || []
),
};
});
}}
/>
{values.template && (
<>
{values.template.Note && (
<div>
<div className="col-sm-12 form-section-title"> Information </div>
<div className="form-group">
<div className="col-sm-12">
<div
className="template-note"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: sanitize(values.template.Note),
}}
/>
</div>
</div>
</div>
)}
<CustomTemplatesVariablesField
onChange={(value) => {
setValues((values) => ({
...values,
variables: value,
}));
}}
value={values.variables}
definitions={values.template.Variables}
errors={errors?.variables}
/>
</>
)}
</>
);
function setValues(values: SetStateAction<Values>) {
setControlledValues(values);
setInitialValues(values);
}
}
function TemplateSelector({
value,
onChange,
error,
}: {
value: CustomTemplate['Id'] | undefined;
onChange: (value: CustomTemplate['Id'] | undefined) => void;
error?: string;
}) {
const templatesQuery = useCustomTemplates({
params: {
edge: true,
},
});
if (!templatesQuery.data) {
return null;
}
return (
<FormControl label="Template" inputId="stack_template" errors={error}>
<PortainerSelect
placeholder="Select an Edge stack template"
value={value}
onChange={handleChange}
options={templatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
value: template.Id,
}))}
/>
</FormControl>
);
function handleChange(value: CustomTemplate['Id']) {
onChange(value);
}
}
export function getInitialTemplateValues() {
return {
template: null,
variables: [],
file: '',
};
}

View file

@ -0,0 +1,55 @@
import { render, screen } from '@/react-tools/test-utils';
import {
EnvVarType,
TemplateViewModel,
} from '@/react/portainer/templates/app-templates/view-model';
import { AppTemplateFieldset } from './AppTemplateFieldset';
test('renders AppTemplateFieldset component', () => {
const testedEnv = {
name: 'VAR2',
label: 'Variable 2',
default: 'value2',
value: 'value2',
type: EnvVarType.Text,
};
const env = [
{
name: 'VAR1',
label: 'Variable 1',
default: 'value1',
value: 'value1',
type: EnvVarType.Text,
},
testedEnv,
];
const template = {
Note: 'This is a template note',
Env: env,
} as TemplateViewModel;
const values: Record<string, string> = {
VAR1: 'value1',
VAR2: 'value2',
};
const onChange = vi.fn();
render(
<AppTemplateFieldset
template={template}
values={values}
onChange={onChange}
/>
);
const templateNoteElement = screen.getByText('This is a template note');
expect(templateNoteElement).toBeInTheDocument();
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
exact: false,
});
expect(envVarsFieldsetElement).toBeInTheDocument();
});

View file

@ -0,0 +1,30 @@
import { FormikErrors } from 'formik';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { EnvVarsFieldset } from './EnvVarsFieldset';
import { TemplateNote } from './TemplateNote';
export function AppTemplateFieldset({
template,
values,
onChange,
errors,
}: {
template: TemplateViewModel;
values: Record<string, string>;
onChange: (value: Record<string, string>) => void;
errors?: FormikErrors<Record<string, string>>;
}) {
return (
<>
<TemplateNote note={template.Note} />
<EnvVarsFieldset
options={template.Env || []}
value={values}
onChange={onChange}
errors={errors}
/>
</>
);
}

View file

@ -0,0 +1,32 @@
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values } from './types';
import { TemplateNote } from './TemplateNote';
export function CustomTemplateFieldset({
errors,
onChange,
values,
template,
}: {
values: Values['variables'];
onChange: (values: Values['variables']) => void;
errors: ArrayError<Values['variables']> | undefined;
template: CustomTemplate;
}) {
return (
<>
<TemplateNote note={template.Note} />
<CustomTemplatesVariablesField
onChange={onChange}
value={values}
definitions={template.Variables}
errors={errors}
/>
</>
);
}

View file

@ -0,0 +1,118 @@
import { vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@/react-tools/test-utils';
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' };
const errors = {};
render(
<EnvVarsFieldset
onChange={onChange}
options={[...options]}
value={value}
errors={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' };
const errors = {};
render(
<EnvVarsFieldset
onChange={onChange}
options={options}
value={value}
errors={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: 'Value 1' };
const errors = { VAR1: 'Required' };
render(
<EnvVarsFieldset
onChange={onChange}
options={options}
value={value}
errors={errors}
/>
);
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();
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,94 @@
import { FormikErrors } from 'formik';
import { SchemaOf, array, 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 function EnvVarsFieldset({
onChange,
options,
value,
errors,
}: {
options: Array<TemplateEnv>;
onChange: (value: Value) => void;
value: Value;
errors?: FormikErrors<Value>;
}) {
return (
<>
{options.map((env) => (
<Item
key={env.name}
option={env}
value={value[env.name]}
onChange={(value) => handleChange(env.name, value)}
errors={errors?.[env.name]}
/>
))}
</>
);
function handleChange(name: string, envValue: string) {
onChange({ ...value, [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}
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}
/>
)}
</FormControl>
);
}
export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
return Object.fromEntries(definitions.map((v) => [v.name, v.default || '']));
}
export function envVarsFieldsetValidation(): SchemaOf<Value> {
return (
array()
.transform((_, orig) => Object.values(orig))
// casting to return the correct type - validation works as expected
.of(string().required('Required')) as unknown as SchemaOf<Value>
);
}

View file

@ -0,0 +1,114 @@
import { SetStateAction, useEffect, useState } from 'react';
import { FormikErrors } from 'formik';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
import { TemplateSelector } from './TemplateSelector';
import { SelectedTemplateValue, Values } from './types';
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
import { AppTemplateFieldset } from './AppTemplateFieldset';
export function TemplateFieldset({
values: initialValues,
setValues: setInitialValues,
errors,
}: {
errors?: FormikErrors<Values>;
values: Values;
setValues: (values: SetStateAction<Values>) => void;
}) {
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
useEffect(() => {
if (
initialValues.type !== values.type ||
initialValues.template?.Id !== values.template?.Id
) {
setControlledValues(initialValues);
}
}, [initialValues, values]);
return (
<>
<TemplateSelector
error={
typeof errors?.template === 'string' ? errors?.template : undefined
}
value={values}
onChange={handleChangeTemplate}
/>
{values.template && (
<>
{values.type === 'custom' && (
<CustomTemplateFieldset
template={values.template}
values={values.variables}
onChange={(variables) =>
setValues((values) => ({ ...values, variables }))
}
errors={errors?.variables}
/>
)}
{values.type === 'app' && (
<AppTemplateFieldset
template={values.template}
values={values.envVars}
onChange={(envVars) =>
setValues((values) => ({ ...values, envVars }))
}
errors={errors?.envVars}
/>
)}
</>
)}
</>
);
function setValues(values: SetStateAction<Values>) {
setControlledValues(values);
setInitialValues(values);
}
function handleChangeTemplate(value?: SelectedTemplateValue) {
setValues(() => {
if (!value || !value.type || !value.template) {
return {
type: undefined,
template: undefined,
variables: [],
envVars: {},
};
}
if (value.type === 'app') {
return {
template: value.template,
type: value.type,
variables: [],
envVars: getAppVariablesDefaultValues(value.template.Env || []),
};
}
return {
template: value.template,
type: value.type,
variables: getVariablesFieldDefaultValues(
value.template.Variables || []
),
envVars: {},
};
});
}
}
export function getInitialTemplateValues(): Values {
return {
template: undefined,
type: undefined,
variables: [],
file: '',
envVars: {},
};
}

View file

@ -0,0 +1,26 @@
import { vi } from 'vitest';
import { render, screen } from '@/react-tools/test-utils';
import { TemplateNote } from './TemplateNote';
vi.mock('sanitize-html', () => ({
default: (note: string) => note, // Mock the sanitize-html library to return the input as is
}));
test('renders template note', async () => {
render(<TemplateNote note="Test note" />);
const templateNoteElement = screen.getByText(/Information/);
expect(templateNoteElement).toBeInTheDocument();
const noteElement = screen.getByText(/Test note/);
expect(noteElement).toBeInTheDocument();
});
test('does not render template note when note is undefined', async () => {
render(<TemplateNote note={undefined} />);
const templateNoteElement = screen.queryByText(/Information/);
expect(templateNoteElement).not.toBeInTheDocument();
});

View file

@ -0,0 +1,23 @@
import sanitize from 'sanitize-html';
export function TemplateNote({ note }: { note: string | undefined }) {
if (!note) {
return null;
}
return (
<div>
<div className="col-sm-12 form-section-title"> Information </div>
<div className="form-group">
<div className="col-sm-12">
<div
className="template-note"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: sanitize(note),
}}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,149 @@
import { vi } from 'vitest';
import { HttpResponse, http } from 'msw';
import { renderWithQueryClient, screen } from '@/react-tools/test-utils';
import { AppTemplate } from '@/react/portainer/templates/app-templates/types';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { SelectedTemplateValue } from './types';
import { TemplateSelector } from './TemplateSelector';
test('renders TemplateSelector component', async () => {
render();
const templateSelectorElement = screen.getByLabelText('Template');
expect(templateSelectorElement).toBeInTheDocument();
});
// eslint-disable-next-line vitest/expect-expect
test('selects an edge app template', async () => {
const onChange = vi.fn();
const selectedTemplate = {
title: 'App Template 2',
description: 'Description 2',
id: 2,
categories: ['edge'],
};
const { select } = render({
onChange,
appTemplates: [
{
title: 'App Template 1',
description: 'Description 1',
id: 1,
categories: ['edge'],
},
selectedTemplate,
],
});
await select('app', {
Title: selectedTemplate.title,
Description: selectedTemplate.description,
});
});
// eslint-disable-next-line vitest/expect-expect
test('selects an edge custom template', async () => {
const onChange = vi.fn();
const selectedTemplate = {
Title: 'Custom Template 2',
Description: 'Description 2',
Id: 2,
};
const { select } = render({
onChange,
customTemplates: [
{
Title: 'Custom Template 1',
Description: 'Description 1',
Id: 1,
},
selectedTemplate,
],
});
await select('custom', selectedTemplate);
});
test('renders with error', async () => {
render({
error: 'Invalid template',
});
const templateSelectorElement = screen.getByLabelText('Template');
expect(templateSelectorElement).toBeInTheDocument();
const errorElement = screen.getByText('Invalid template');
expect(errorElement).toBeInTheDocument();
});
test('renders TemplateSelector component with no custom templates available', async () => {
render({
customTemplates: [],
});
const templateSelectorElement = screen.getByLabelText('Template');
expect(templateSelectorElement).toBeInTheDocument();
await selectEvent.openMenu(templateSelectorElement);
const noCustomTemplatesElement = screen.getByText(
'No edge custom templates available'
);
expect(noCustomTemplatesElement).toBeInTheDocument();
});
function render({
onChange = vi.fn(),
appTemplates = [],
customTemplates = [],
error,
}: {
onChange?: (value: SelectedTemplateValue) => void;
appTemplates?: Array<Partial<AppTemplate>>;
customTemplates?: Array<Partial<CustomTemplate>>;
error?: string;
} = {}) {
server.use(
http.get('/api/registries', async () => HttpResponse.json([])),
http.get('/api/templates', async () =>
HttpResponse.json({ templates: appTemplates, version: '3' })
),
http.get('/api/custom_templates', async () =>
HttpResponse.json(customTemplates)
)
);
renderWithQueryClient(
<TemplateSelector
value={{ template: undefined, type: undefined }}
onChange={onChange}
error={error}
/>
);
return { select };
async function select(
type: 'app' | 'custom',
template: { Title: string; Description: string }
) {
const templateSelectorElement = screen.getByLabelText('Template');
await selectEvent.select(
templateSelectorElement,
`${template.Title} - ${template.Description}`
);
expect(onChange).toHaveBeenCalledWith({
template: expect.objectContaining(template),
type,
});
}
}

View file

@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { GroupBase } from 'react-select';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { FormControl } from '@@/form-components/FormControl';
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
import { SelectedTemplateValue } from './types';
export function TemplateSelector({
value,
onChange,
error,
}: {
value: SelectedTemplateValue;
onChange: (value: SelectedTemplateValue) => void;
error?: string;
}) {
const { getTemplate, options } = useOptions();
return (
<FormControl label="Template" inputId="template_selector" errors={error}>
<ReactSelect
inputId="template_selector"
formatGroupLabel={GroupLabel}
placeholder="Select an Edge stack template"
value={{
label: value.template?.Title,
id: value.template?.Id,
type: value.type,
}}
onChange={(value) => {
if (!value) {
onChange({
template: undefined,
type: undefined,
});
return;
}
const { id, type } = value;
if (!id || type === undefined) {
return;
}
const template = getTemplate({ id, type });
onChange({ template, type } as SelectedTemplateValue);
}}
options={options}
/>
</FormControl>
);
}
function useOptions() {
const customTemplatesQuery = useCustomTemplates({
params: {
edge: true,
},
});
const appTemplatesQuery = useAppTemplates({
select: (templates) =>
templates.filter(
(template) =>
template.Categories.includes('edge') &&
template.Type !== TemplateType.Container
),
});
const options = useMemo(
() =>
[
{
label: 'Edge App Templates',
options:
appTemplatesQuery.data?.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
type: 'app' as 'app' | 'custom',
})) || [],
},
{
label: 'Edge Custom Templates',
options:
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
? customTemplatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
type: 'custom' as 'app' | 'custom',
}))
: [
{
label: 'No edge custom templates available',
id: 0,
type: 'custom' as 'app' | 'custom',
},
],
},
] as const,
[appTemplatesQuery.data, customTemplatesQuery.data]
);
return { options, getTemplate };
function getTemplate({ type, id }: { type: 'app' | 'custom'; id: number }) {
if (type === 'app') {
const template = appTemplatesQuery.data?.find(
(template) => template.Id === id
);
if (!template) {
throw new Error(`App template not found: ${id}`);
}
return template;
}
const template = customTemplatesQuery.data?.find(
(template) => template.Id === id
);
if (!template) {
throw new Error(`Custom template not found: ${id}`);
}
return template;
}
}
function GroupLabel({ label }: GroupBase<unknown>) {
return (
<span className="font-bold text-black th-dark:text-white th-highcontrast:text-white">
{label}
</span>
);
}

View file

@ -0,0 +1,14 @@
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
export type SelectedTemplateValue =
| { template: CustomTemplate; type: 'custom' }
| { template: TemplateViewModel; type: 'app' }
| { template: undefined; type: undefined };
export type Values = {
file?: string;
variables: VariablesFieldValue;
envVars: Record<string, string>;
} & SelectedTemplateValue;

View file

@ -0,0 +1,32 @@
import { mixed, object, SchemaOf, string } from 'yup';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { envVarsFieldsetValidation } from './EnvVarsFieldset';
export function validation({
definitions,
}: {
definitions: VariableDefinition[];
}) {
return object({
type: string().oneOf(['custom', 'app']).required(),
envVars: envVarsFieldsetValidation()
.optional()
.when('type', {
is: 'app',
then: (schema: SchemaOf<unknown, never>) => schema.required(),
}),
file: mixed().optional(),
template: object().optional().default(null),
variables: variablesFieldValidation(definitions)
.optional()
.when('type', {
is: 'custom',
then: (schema) => schema.required(),
}),
});
}
export { validation as templateFieldsetValidation };