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:
parent
31f5b42962
commit
437831fa80
34 changed files with 1293 additions and 482 deletions
|
@ -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: '',
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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: {},
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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 };
|
Loading…
Add table
Add a link
Reference in a new issue