mirror of https://github.com/portainer/portainer
feat(edge/stacks): add app templates to deploy types [EE-6632] (#11040)
parent
31f5b42962
commit
437831fa80
|
@ -62,7 +62,7 @@ angular
|
||||||
|
|
||||||
const stacksNew = {
|
const stacksNew = {
|
||||||
name: 'edge.stacks.new',
|
name: 'edge.stacks.new',
|
||||||
url: '/new?templateId',
|
url: '/new?templateId&templateType',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'createEdgeStackView',
|
component: 'createEdgeStackView',
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.edge.react.components', [])
|
.module('portainer.edge.react.components', [])
|
||||||
|
|
|
@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types';
|
||||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||||
|
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
|
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||||
|
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||||
|
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
|
||||||
|
|
||||||
export default class CreateEdgeStackViewController {
|
export default class CreateEdgeStackViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction
|
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
|
||||||
*/
|
*/
|
||||||
setTemplateValues(templateAction) {
|
setTemplateValues(templateAction) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
|
@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController {
|
||||||
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
|
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
|
||||||
this.state.templateValues = newTemplateValues;
|
this.state.templateValues = newTemplateValues;
|
||||||
if (newTemplateId !== oldTemplateId) {
|
if (newTemplateId !== oldTemplateId) {
|
||||||
await this.onChangeTemplate(newTemplateValues.template);
|
await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
|
||||||
}
|
}
|
||||||
|
|
||||||
let definitions = [];
|
if (newTemplateValues.type === 'custom') {
|
||||||
if (this.state.templateValues.template) {
|
const definitions = this.state.templateValues.template.Variables;
|
||||||
definitions = this.state.templateValues.template.Variables;
|
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
|
||||||
}
|
|
||||||
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
|
|
||||||
|
|
||||||
this.formValues.StackFileContent = newFile;
|
this.formValues.StackFileContent = newFile;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeTemplate(template) {
|
onChangeTemplate(type, template) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.templateValues.template = template;
|
if (type === 'custom') {
|
||||||
this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables);
|
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
||||||
|
this.state.templateValues.file = fileContent;
|
||||||
|
|
||||||
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
this.formValues = {
|
||||||
this.state.templateValues.file = fileContent;
|
...this.formValues,
|
||||||
|
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
|
||||||
|
...toGitFormModel(template.GitConfig),
|
||||||
|
...(template.EdgeSettings
|
||||||
|
? {
|
||||||
|
PrePullImage: template.EdgeSettings.PrePullImage || false,
|
||||||
|
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
|
||||||
|
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
|
||||||
|
...template.EdgeSettings.RelativePathSettings,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.formValues = {
|
if (type === 'app') {
|
||||||
...this.formValues,
|
this.formValues.StackFileContent = '';
|
||||||
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
|
try {
|
||||||
...toGitFormModel(template.GitConfig),
|
const fileContent = await fetchFilePreview(template.Id);
|
||||||
...(template.EdgeSettings
|
this.formValues.StackFileContent = fileContent;
|
||||||
? {
|
} catch (err) {
|
||||||
PrePullImage: template.EdgeSettings.PrePullImage || false,
|
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
||||||
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
|
}
|
||||||
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
|
}
|
||||||
...template.EdgeSettings.RelativePathSettings,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async preSelectTemplate(templateId) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {'app' | 'custom'} templateType
|
||||||
|
* @param {number} templateId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async preSelectTemplate(templateType, templateId) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
try {
|
try {
|
||||||
this.state.Method = 'template';
|
this.state.Method = 'template';
|
||||||
const template = await getCustomTemplate(templateId);
|
const template = await getTemplate(templateType, templateId);
|
||||||
|
if (!template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.setTemplateValues({ template });
|
this.setTemplateValues({
|
||||||
|
template,
|
||||||
|
type: templateType,
|
||||||
|
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
|
||||||
|
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyError('Failed loading template', e);
|
notifyError('Failed loading template', e);
|
||||||
}
|
}
|
||||||
|
@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = this.$state.params.templateId;
|
const templateId = parseInt(this.$state.params.templateId, 10);
|
||||||
if (templateId) {
|
const templateType = this.$state.params.templateType;
|
||||||
this.preSelectTemplate(templateId);
|
if (templateType && templateId && !Number.isNaN(templateId)) {
|
||||||
|
this.preSelectTemplate(templateType, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$window.onbeforeunload = () => {
|
this.$window.onbeforeunload = () => {
|
||||||
|
@ -198,6 +225,12 @@ export default class CreateEdgeStackViewController {
|
||||||
createStack() {
|
createStack() {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
const name = this.formValues.Name;
|
const name = this.formValues.Name;
|
||||||
|
|
||||||
|
let envVars = this.formValues.envVars;
|
||||||
|
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
|
||||||
|
envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
|
||||||
|
}
|
||||||
|
|
||||||
const method = getMethod(this.state.Method, this.state.templateValues.template);
|
const method = getMethod(this.state.Method, this.state.templateValues.template);
|
||||||
|
|
||||||
if (!this.validateForm(method)) {
|
if (!this.validateForm(method)) {
|
||||||
|
@ -206,7 +239,7 @@ export default class CreateEdgeStackViewController {
|
||||||
|
|
||||||
this.state.actionInProgress = true;
|
this.state.actionInProgress = true;
|
||||||
try {
|
try {
|
||||||
await this.createStackByMethod(name, method);
|
await this.createStackByMethod(name, method, envVars);
|
||||||
|
|
||||||
this.Notifications.success('Success', 'Stack successfully deployed');
|
this.Notifications.success('Success', 'Stack successfully deployed');
|
||||||
this.state.isEditorDirty = false;
|
this.state.isEditorDirty = false;
|
||||||
|
@ -258,19 +291,19 @@ export default class CreateEdgeStackViewController {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackByMethod(name, method) {
|
createStackByMethod(name, method, envVars) {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'editor':
|
case 'editor':
|
||||||
return this.createStackFromFileContent(name);
|
return this.createStackFromFileContent(name, envVars);
|
||||||
case 'upload':
|
case 'upload':
|
||||||
return this.createStackFromFileUpload(name);
|
return this.createStackFromFileUpload(name, envVars);
|
||||||
case 'repository':
|
case 'repository':
|
||||||
return this.createStackFromGitRepository(name);
|
return this.createStackFromGitRepository(name, envVars);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromFileContent(name) {
|
createStackFromFileContent(name, envVars) {
|
||||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||||
|
|
||||||
return this.EdgeStackService.createStackFromFileContent({
|
return this.EdgeStackService.createStackFromFileContent({
|
||||||
name,
|
name,
|
||||||
|
@ -282,8 +315,9 @@ export default class CreateEdgeStackViewController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromFileUpload(name) {
|
createStackFromFileUpload(name, envVars) {
|
||||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||||
|
|
||||||
return this.EdgeStackService.createStackFromFileUpload(
|
return this.EdgeStackService.createStackFromFileUpload(
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
@ -296,8 +330,9 @@ export default class CreateEdgeStackViewController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromGitRepository(name) {
|
async createStackFromGitRepository(name, envVars) {
|
||||||
const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||||
|
|
||||||
const repositoryOptions = {
|
const repositoryOptions = {
|
||||||
RepositoryURL: this.formValues.RepositoryURL,
|
RepositoryURL: this.formValues.RepositoryURL,
|
||||||
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
||||||
|
@ -354,3 +389,25 @@ function getMethod(method, template) {
|
||||||
}
|
}
|
||||||
return 'editor';
|
return 'editor';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {'app' | 'custom'} templateType
|
||||||
|
* @param {number} templateId
|
||||||
|
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
|
||||||
|
*/
|
||||||
|
async function getTemplate(templateType, templateId) {
|
||||||
|
if (!['app', 'custom'].includes(templateType)) {
|
||||||
|
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateType === 'app') {
|
||||||
|
const templatesResponse = await getAppTemplates();
|
||||||
|
const template = templatesResponse.templates.find((t) => t.id === templateId);
|
||||||
|
return new TemplateViewModel(template, templatesResponse.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await getCustomTemplate(templateId);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||||
|
|
||||||
class DockerComposeFormController {
|
class DockerComposeFormController {
|
||||||
|
|
|
@ -48,6 +48,7 @@ export const ngModule = angular
|
||||||
'disabledTypes',
|
'disabledTypes',
|
||||||
'fixedCategories',
|
'fixedCategories',
|
||||||
'storageKey',
|
'storageKey',
|
||||||
|
'templateLinkParams',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
|
|
@ -37,7 +37,7 @@ export const edgeStackTemplate: BoxSelectorOption<'template'> = {
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
iconType: 'badge',
|
iconType: 'badge',
|
||||||
label: 'Template',
|
label: 'Template',
|
||||||
description: 'Use an Edge stack template',
|
description: 'Use an Edge stack app or custom template',
|
||||||
value: 'template',
|
value: 'template',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
|
@ -1,4 +1,5 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
import { SchemaOf, array, string } from 'yup';
|
||||||
|
|
||||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
|
||||||
|
@ -20,13 +21,13 @@ export function EnvVarsFieldset({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.map((env, index) => (
|
{options.map((env) => (
|
||||||
<Item
|
<Item
|
||||||
key={env.name}
|
key={env.name}
|
||||||
option={env}
|
option={env}
|
||||||
value={value[env.name]}
|
value={value[env.name]}
|
||||||
onChange={(value) => handleChange(env.name, value)}
|
onChange={(value) => handleChange(env.name, value)}
|
||||||
errors={errors?.[index]}
|
errors={errors?.[env.name]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -48,11 +49,13 @@ function Item({
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
errors?: FormikErrors<string>;
|
errors?: FormikErrors<string>;
|
||||||
}) {
|
}) {
|
||||||
|
const inputId = `env_var_${option.name}`;
|
||||||
return (
|
return (
|
||||||
<FormControl
|
<FormControl
|
||||||
label={option.label || option.name}
|
label={option.label || option.name}
|
||||||
required={!option.preset}
|
required={!option.preset}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
inputId={inputId}
|
||||||
>
|
>
|
||||||
{option.select ? (
|
{option.select ? (
|
||||||
<Select
|
<Select
|
||||||
|
@ -63,14 +66,29 @@ function Item({
|
||||||
value: o.value,
|
value: o.value,
|
||||||
}))}
|
}))}
|
||||||
disabled={option.preset}
|
disabled={option.preset}
|
||||||
|
id={inputId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
disabled={option.preset}
|
disabled={option.preset}
|
||||||
|
id={inputId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</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 };
|
|
@ -1,37 +1,22 @@
|
||||||
import { useParamState } from '@/react/hooks/useParamState';
|
|
||||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
|
||||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
import { DeployFormWidget } from './DeployForm';
|
|
||||||
|
|
||||||
export function AppTemplatesView() {
|
export function AppTemplatesView() {
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
|
|
||||||
'template',
|
|
||||||
(param) => (param ? parseInt(param, 10) : 0)
|
|
||||||
);
|
|
||||||
const templatesQuery = useAppTemplates();
|
const templatesQuery = useAppTemplates();
|
||||||
const selectedTemplate = selectedTemplateId
|
|
||||||
? templatesQuery.data?.find(
|
|
||||||
(template) => template.Id === selectedTemplateId
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
<PageHeader title="Application templates list" breadcrumbs="Templates" />
|
||||||
{selectedTemplate && (
|
|
||||||
<DeployFormWidget
|
|
||||||
template={selectedTemplate}
|
|
||||||
unselect={() => setSelectedTemplateId()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AppTemplatesList
|
<AppTemplatesList
|
||||||
templates={templatesQuery.data}
|
templates={templatesQuery.data}
|
||||||
selectedId={selectedTemplateId}
|
templateLinkParams={(template) => ({
|
||||||
onSelect={(template) => setSelectedTemplateId(template.Id)}
|
to: 'edge.stacks.new',
|
||||||
|
params: { templateId: template.Id, templateType: 'app' },
|
||||||
|
})}
|
||||||
disabledTypes={[TemplateType.Container]}
|
disabledTypes={[TemplateType.Container]}
|
||||||
fixedCategories={['edge']}
|
fixedCategories={['edge']}
|
||||||
storageKey="edge-app-templates"
|
storageKey="edge-app-templates"
|
||||||
|
|
|
@ -1,196 +0,0 @@
|
||||||
import { Rocket } from 'lucide-react';
|
|
||||||
import { Form, Formik } from 'formik';
|
|
||||||
import { array, lazy, number, object, string } from 'yup';
|
|
||||||
import { useRouter } from '@uirouter/react';
|
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
|
||||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
|
||||||
import { FallbackImage } from '@@/FallbackImage';
|
|
||||||
import { Icon } from '@@/Icon';
|
|
||||||
import { FormActions } from '@@/form-components/FormActions';
|
|
||||||
import { Button } from '@@/buttons';
|
|
||||||
|
|
||||||
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
|
|
||||||
import {
|
|
||||||
NameField,
|
|
||||||
nameValidation,
|
|
||||||
} from '../../edge-stacks/CreateView/NameField';
|
|
||||||
import { EdgeGroup } from '../../edge-groups/types';
|
|
||||||
import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
|
|
||||||
import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
|
|
||||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
|
||||||
import { useCreateEdgeStack } from '../../edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack';
|
|
||||||
|
|
||||||
import { EnvVarsFieldset } from './EnvVarsFieldset';
|
|
||||||
|
|
||||||
export function DeployFormWidget({
|
|
||||||
template,
|
|
||||||
unselect,
|
|
||||||
}: {
|
|
||||||
template: TemplateViewModel;
|
|
||||||
unselect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-sm-12">
|
|
||||||
<Widget>
|
|
||||||
<Widget.Title
|
|
||||||
icon={
|
|
||||||
<FallbackImage
|
|
||||||
src={template.Logo}
|
|
||||||
fallbackIcon={<Icon icon={Rocket} />}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={template.Title}
|
|
||||||
/>
|
|
||||||
<Widget.Body>
|
|
||||||
<DeployForm template={template} unselect={unselect} />
|
|
||||||
</Widget.Body>
|
|
||||||
</Widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormValues {
|
|
||||||
name: string;
|
|
||||||
edgeGroupIds: Array<EdgeGroup['Id']>;
|
|
||||||
envVars: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeployForm({
|
|
||||||
template,
|
|
||||||
unselect,
|
|
||||||
}: {
|
|
||||||
template: TemplateViewModel;
|
|
||||||
unselect: () => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const mutation = useCreateEdgeStack();
|
|
||||||
const edgeStacksQuery = useEdgeStacks();
|
|
||||||
const edgeGroupsQuery = useEdgeGroups({
|
|
||||||
select: (groups) =>
|
|
||||||
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialValues: FormValues = {
|
|
||||||
edgeGroupIds: [],
|
|
||||||
name: template.Name || '',
|
|
||||||
envVars:
|
|
||||||
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
|
|
||||||
{},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
validationSchema={() =>
|
|
||||||
validation(edgeStacksQuery.data, edgeGroupsQuery.data)
|
|
||||||
}
|
|
||||||
validateOnMount
|
|
||||||
>
|
|
||||||
{({ values, errors, setFieldValue, isValid }) => (
|
|
||||||
<Form className="form-horizontal">
|
|
||||||
<NameField
|
|
||||||
value={values.name}
|
|
||||||
onChange={(v) => setFieldValue('name', v)}
|
|
||||||
errors={errors.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EdgeGroupsSelector
|
|
||||||
horizontal
|
|
||||||
value={values.edgeGroupIds}
|
|
||||||
error={errors.edgeGroupIds}
|
|
||||||
onChange={(value) => setFieldValue('edgeGroupIds', value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnvVarsFieldset
|
|
||||||
value={values.envVars}
|
|
||||||
options={template.Env}
|
|
||||||
errors={errors.envVars}
|
|
||||||
onChange={(values) => setFieldValue('envVars', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormActions
|
|
||||||
isLoading={mutation.isLoading}
|
|
||||||
isValid={isValid}
|
|
||||||
loadingText="Deployment in progress..."
|
|
||||||
submitLabel="Deploy the stack"
|
|
||||||
>
|
|
||||||
<Button type="reset" onClick={() => unselect()} color="default">
|
|
||||||
Hide
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSubmit(values: FormValues) {
|
|
||||||
return mutation.mutate(
|
|
||||||
{
|
|
||||||
method: 'git',
|
|
||||||
payload: {
|
|
||||||
name: values.name,
|
|
||||||
edgeGroups: values.edgeGroupIds,
|
|
||||||
deploymentType: DeploymentType.Compose,
|
|
||||||
|
|
||||||
envVars: Object.entries(values.envVars).map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
})),
|
|
||||||
git: {
|
|
||||||
RepositoryURL: template.Repository.url,
|
|
||||||
ComposeFilePathInRepository: template.Repository.stackfile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess('Success', 'Edge Stack created');
|
|
||||||
router.stateService.go('edge.stacks');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validation(
|
|
||||||
stacks: EdgeStack[],
|
|
||||||
edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
|
|
||||||
) {
|
|
||||||
return lazy((values: FormValues) => {
|
|
||||||
const types = getTypes(values.edgeGroupIds);
|
|
||||||
|
|
||||||
return object({
|
|
||||||
name: nameValidation(
|
|
||||||
stacks,
|
|
||||||
types?.includes(EnvironmentType.EdgeAgentOnDocker)
|
|
||||||
),
|
|
||||||
edgeGroupIds: array(number().required().default(0))
|
|
||||||
.min(1, 'At least one group is required')
|
|
||||||
.test(
|
|
||||||
'same-type',
|
|
||||||
'Groups should be of the same type',
|
|
||||||
(value) => _.uniq(getTypes(value)).length === 1
|
|
||||||
),
|
|
||||||
envVars: array()
|
|
||||||
.transform((_, orig) => Object.values(orig))
|
|
||||||
.of(string().required('Required')),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getTypes(value: number[] | undefined) {
|
|
||||||
return value?.flatMap((g) => edgeGroupsType[g]);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -24,7 +24,7 @@ export function ListView() {
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
templateLinkParams={(template) => ({
|
templateLinkParams={(template) => ({
|
||||||
to: 'edge.stacks.new',
|
to: 'edge.stacks.new',
|
||||||
params: { templateId: template.Id },
|
params: { templateId: template.Id, templateType: 'custom' },
|
||||||
})}
|
})}
|
||||||
storageKey="edge-custom-templates"
|
storageKey="edge-custom-templates"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { render, screen } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomTemplatesVariablesField,
|
||||||
|
Values,
|
||||||
|
} from './CustomTemplatesVariablesField';
|
||||||
|
|
||||||
|
test('renders CustomTemplatesVariablesField component', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const definitions = [
|
||||||
|
{
|
||||||
|
name: 'Variable1',
|
||||||
|
label: 'Variable 1',
|
||||||
|
description: 'Description 1',
|
||||||
|
defaultValue: 'Default 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Variable2',
|
||||||
|
label: 'Variable 2',
|
||||||
|
description: 'Description 2',
|
||||||
|
defaultValue: 'Default 2',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const value: Values = [
|
||||||
|
{ key: 'Variable1', value: 'Value 1' },
|
||||||
|
{ key: 'Variable2', value: 'Value 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomTemplatesVariablesField
|
||||||
|
onChange={onChange}
|
||||||
|
definitions={definitions}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const variableFieldItems = screen.getAllByLabelText(/Variable \d/);
|
||||||
|
expect(variableFieldItems).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onChange when variable value is changed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const definitions = [
|
||||||
|
{
|
||||||
|
name: 'Variable1',
|
||||||
|
label: 'Variable 1',
|
||||||
|
description: 'Description 1',
|
||||||
|
defaultValue: 'Default 1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomTemplatesVariablesField
|
||||||
|
onChange={onChange}
|
||||||
|
definitions={definitions}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputElement = screen.getByLabelText('Variable 1');
|
||||||
|
|
||||||
|
await user.clear(inputElement);
|
||||||
|
expect(onChange).toHaveBeenCalledWith([{ key: 'Variable1', value: '' }]);
|
||||||
|
|
||||||
|
await user.type(inputElement, 'New Value');
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders error message when errors prop is provided', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const definitions = [
|
||||||
|
{
|
||||||
|
name: 'Variable1',
|
||||||
|
label: 'Variable 1',
|
||||||
|
description: 'Description 1',
|
||||||
|
defaultValue: 'Default 1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
|
||||||
|
const errors = [{ value: 'Error message' }];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomTemplatesVariablesField
|
||||||
|
onChange={onChange}
|
||||||
|
definitions={definitions}
|
||||||
|
value={value}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorElement = screen.getByText('Error message');
|
||||||
|
expect(errorElement).toBeInTheDocument();
|
||||||
|
});
|
|
@ -1,13 +1,11 @@
|
||||||
import { array, object, string } from 'yup';
|
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
|
||||||
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||||
import { Input } from '@@/form-components/Input';
|
|
||||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||||
import { FormError } from '@@/form-components/FormError';
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||||
|
|
||||||
|
import { VariableFieldItem } from './VariableFieldItem';
|
||||||
|
|
||||||
export type Values = Array<{ key: string; value?: string }>;
|
export type Values = Array<{ key: string; value?: string }>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -33,8 +31,8 @@ export function CustomTemplatesVariablesField({
|
||||||
<VariableFieldItem
|
<VariableFieldItem
|
||||||
key={definition.name}
|
key={definition.name}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
value={value.find((v) => v.key === definition.name)?.value || ''}
|
|
||||||
error={getError(errors, index)}
|
error={getError(errors, index)}
|
||||||
|
value={value.find((v) => v.key === definition.name)?.value}
|
||||||
onChange={(fieldValue) => {
|
onChange={(fieldValue) => {
|
||||||
onChange(
|
onChange(
|
||||||
value.map((v) =>
|
value.map((v) =>
|
||||||
|
@ -50,39 +48,6 @@ export function CustomTemplatesVariablesField({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VariableFieldItem({
|
|
||||||
definition,
|
|
||||||
value,
|
|
||||||
error,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
definition: VariableDefinition;
|
|
||||||
value: string;
|
|
||||||
error?: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const inputId = `${definition.name}-input`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormControl
|
|
||||||
required={!definition.defaultValue}
|
|
||||||
label={definition.label}
|
|
||||||
key={definition.name}
|
|
||||||
inputId={inputId}
|
|
||||||
tooltip={definition.description}
|
|
||||||
size="small"
|
|
||||||
errors={error}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name={`variables.${definition.name}`}
|
|
||||||
value={value}
|
|
||||||
id={inputId}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getError(errors: ArrayError<Values> | undefined, index: number) {
|
function getError(errors: ArrayError<Values> | undefined, index: number) {
|
||||||
if (!errors || typeof errors !== 'object') {
|
if (!errors || typeof errors !== 'object') {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -95,22 +60,3 @@ function getError(errors: ArrayError<Values> | undefined, index: number) {
|
||||||
|
|
||||||
return typeof error === 'object' ? error.value : error;
|
return typeof error === 'object' ? error.value : error;
|
||||||
}
|
}
|
||||||
export function validation(definitions: VariableDefinition[]) {
|
|
||||||
return array(
|
|
||||||
object({
|
|
||||||
key: string().default(''),
|
|
||||||
value: string().default(''),
|
|
||||||
}).test('required-if-no-default-value', 'This field is required', (obj) => {
|
|
||||||
const definition = definitions.find((d) => d.name === obj.key);
|
|
||||||
if (!definition) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!definition.defaultValue && !obj.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { render, screen } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { VariableFieldItem } from './VariableFieldItem';
|
||||||
|
|
||||||
|
test('renders VariableFieldItem component', () => {
|
||||||
|
const definition = {
|
||||||
|
name: 'variableName',
|
||||||
|
label: 'Variable Label',
|
||||||
|
description: 'Variable Description',
|
||||||
|
defaultValue: 'Default Value',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<VariableFieldItem definition={definition} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const labelElement = screen.getByText('Variable Label');
|
||||||
|
expect(labelElement).toBeInTheDocument();
|
||||||
|
|
||||||
|
const inputElement = screen.getByPlaceholderText(
|
||||||
|
'Enter value or leave blank to use default of Default Value'
|
||||||
|
);
|
||||||
|
expect(inputElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onChange when input value changes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const definition = {
|
||||||
|
name: 'variableName',
|
||||||
|
label: 'Variable Label',
|
||||||
|
description: 'Variable Description',
|
||||||
|
defaultValue: 'Default Value',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<VariableFieldItem
|
||||||
|
definition={definition}
|
||||||
|
onChange={onChange}
|
||||||
|
value="value"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputElement = screen.getByLabelText(definition.label);
|
||||||
|
|
||||||
|
await user.clear(inputElement);
|
||||||
|
expect(onChange).toHaveBeenCalledWith('');
|
||||||
|
|
||||||
|
await user.type(inputElement, 'New Value');
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||||
|
|
||||||
|
export function VariableFieldItem({
|
||||||
|
definition,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
definition: VariableDefinition;
|
||||||
|
error?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
value?: string;
|
||||||
|
}) {
|
||||||
|
const inputId = `${definition.name}-input`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
required={!definition.defaultValue}
|
||||||
|
label={definition.label}
|
||||||
|
key={definition.name}
|
||||||
|
inputId={inputId}
|
||||||
|
tooltip={definition.description}
|
||||||
|
size="small"
|
||||||
|
errors={error}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name={`variables.${definition.name}`}
|
||||||
|
id={inputId}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`Enter value or leave blank to use default of ${definition.defaultValue}`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
export {
|
export {
|
||||||
CustomTemplatesVariablesField,
|
CustomTemplatesVariablesField,
|
||||||
type Values as VariablesFieldValue,
|
type Values as VariablesFieldValue,
|
||||||
validation as variablesFieldValidation,
|
|
||||||
} from './CustomTemplatesVariablesField';
|
} from './CustomTemplatesVariablesField';
|
||||||
|
|
||||||
|
export { validation as variablesFieldValidation } from './validation';
|
||||||
|
|
||||||
export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';
|
export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { array, object, string } from 'yup';
|
||||||
|
|
||||||
|
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||||
|
|
||||||
|
export function validation(definitions: VariableDefinition[]) {
|
||||||
|
return array(
|
||||||
|
object({
|
||||||
|
key: string().default(''),
|
||||||
|
value: string().default(''),
|
||||||
|
}).test('required-if-no-default-value', 'This field is required', (obj) => {
|
||||||
|
const definition = definitions.find((d) => d.name === obj.key);
|
||||||
|
if (!definition) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!definition.defaultValue && !obj.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -21,13 +21,18 @@ export function AppTemplatesList({
|
||||||
disabledTypes,
|
disabledTypes,
|
||||||
fixedCategories,
|
fixedCategories,
|
||||||
storageKey,
|
storageKey,
|
||||||
|
templateLinkParams,
|
||||||
}: {
|
}: {
|
||||||
storageKey: string;
|
storageKey: string;
|
||||||
templates?: TemplateViewModel[];
|
templates?: TemplateViewModel[];
|
||||||
onSelect: (template: TemplateViewModel) => void;
|
onSelect?: (template: TemplateViewModel) => void;
|
||||||
selectedId?: TemplateViewModel['Id'];
|
selectedId?: TemplateViewModel['Id'];
|
||||||
disabledTypes?: Array<TemplateType>;
|
disabledTypes?: Array<TemplateType>;
|
||||||
fixedCategories?: Array<string>;
|
fixedCategories?: Array<string>;
|
||||||
|
templateLinkParams?: (template: TemplateViewModel) => {
|
||||||
|
to: string;
|
||||||
|
params: object;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [store] = useState(() =>
|
const [store] = useState(() =>
|
||||||
|
@ -88,6 +93,7 @@ export function AppTemplatesList({
|
||||||
template={template}
|
template={template}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
isSelected={selectedId === template.Id}
|
isSelected={selectedId === template.Id}
|
||||||
|
linkParams={templateLinkParams?.(template)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!templates && <div className="text-muted text-center">Loading...</div>}
|
{!templates && <div className="text-muted text-center">Loading...</div>}
|
||||||
|
|
|
@ -12,10 +12,12 @@ export function AppTemplatesListItem({
|
||||||
template,
|
template,
|
||||||
onSelect,
|
onSelect,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
linkParams,
|
||||||
}: {
|
}: {
|
||||||
template: TemplateViewModel;
|
template: TemplateViewModel;
|
||||||
onSelect: (template: TemplateViewModel) => void;
|
onSelect?: (template: TemplateViewModel) => void;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
linkParams?: { to: string; params: object };
|
||||||
}) {
|
}) {
|
||||||
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
|
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
|
||||||
|
|
||||||
|
@ -25,7 +27,8 @@ export function AppTemplatesListItem({
|
||||||
typeLabel={
|
typeLabel={
|
||||||
template.Type === TemplateType.Container ? 'container' : 'stack'
|
template.Type === TemplateType.Container ? 'container' : 'stack'
|
||||||
}
|
}
|
||||||
onSelect={() => onSelect(template)}
|
linkParams={linkParams}
|
||||||
|
onSelect={() => onSelect?.(template)}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
renderActions={
|
renderActions={
|
||||||
duplicateCustomTemplateType && (
|
duplicateCustomTemplateType && (
|
||||||
|
|
|
@ -10,7 +10,9 @@ import { TemplateViewModel } from '../view-model';
|
||||||
|
|
||||||
import { buildUrl } from './build-url';
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
export function useAppTemplates() {
|
export function useAppTemplates<T = Array<TemplateViewModel>>({
|
||||||
|
select,
|
||||||
|
}: { select?: (templates: Array<TemplateViewModel>) => T } = {}) {
|
||||||
const registriesQuery = useRegistries();
|
const registriesQuery = useRegistries();
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
|
@ -18,6 +20,7 @@ export function useAppTemplates() {
|
||||||
() => getTemplatesWithRegistry(registriesQuery.data),
|
() => getTemplatesWithRegistry(registriesQuery.data),
|
||||||
{
|
{
|
||||||
enabled: !!registriesQuery.data,
|
enabled: !!registriesQuery.data,
|
||||||
|
select,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +32,7 @@ async function getTemplatesWithRegistry(
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { templates, version } = await getTemplates();
|
const { templates, version } = await getAppTemplates();
|
||||||
return templates.map((item) => {
|
return templates.map((item) => {
|
||||||
const template = new TemplateViewModel(item, version);
|
const template = new TemplateViewModel(item, version);
|
||||||
const registryURL = item.registry;
|
const registryURL = item.registry;
|
||||||
|
@ -41,7 +44,7 @@ async function getTemplatesWithRegistry(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTemplates() {
|
export async function getAppTemplates() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<{
|
const { data } = await axios.get<{
|
||||||
version: string;
|
version: string;
|
||||||
|
|
|
@ -150,7 +150,7 @@ function templateVolumes(data: AppTemplate) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EnvVarType {
|
export enum EnvVarType {
|
||||||
PreSelected = 1,
|
PreSelected = 1,
|
||||||
Text = 2,
|
Text = 2,
|
||||||
Select = 3,
|
Select = 3,
|
||||||
|
|
|
@ -0,0 +1,188 @@
|
||||||
|
/** Simulate user events on react-select dropdowns
|
||||||
|
*
|
||||||
|
* taken from https://github.com/lokalise/react-select-event/blob/migrate-to-user-event/src/index.ts
|
||||||
|
* until package is updated
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Matcher,
|
||||||
|
findAllByText,
|
||||||
|
findByText,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/dom';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// find the react-select container from its input field 🤷
|
||||||
|
function getReactSelectContainerFromInput(input: HTMLElement): HTMLElement {
|
||||||
|
return input.parentNode!.parentNode!.parentNode!.parentNode!
|
||||||
|
.parentNode as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = ReturnType<typeof userEvent.setup> | typeof userEvent;
|
||||||
|
|
||||||
|
type UserEventOptions = {
|
||||||
|
user?: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for opening the select's dropdown menu.
|
||||||
|
* @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
|
||||||
|
*/
|
||||||
|
export async function openMenu(
|
||||||
|
input: HTMLElement,
|
||||||
|
{ user = userEvent }: UserEventOptions = {}
|
||||||
|
) {
|
||||||
|
await user.click(input);
|
||||||
|
await user.type(input, '{ArrowDown}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// type text in the input field
|
||||||
|
async function type(
|
||||||
|
input: HTMLElement,
|
||||||
|
text: string,
|
||||||
|
{ user }: Required<UserEventOptions>
|
||||||
|
) {
|
||||||
|
await user.type(input, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// press the "clear" button, and reset various states
|
||||||
|
async function clear(
|
||||||
|
clearButton: Element,
|
||||||
|
{ user }: Required<UserEventOptions>
|
||||||
|
) {
|
||||||
|
await user.click(clearButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config extends UserEventOptions {
|
||||||
|
/** A container where the react-select dropdown gets rendered to.
|
||||||
|
* Useful when rendering the dropdown in a portal using `menuPortalTarget`.
|
||||||
|
*/
|
||||||
|
container?: HTMLElement | (() => HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for selecting a value in a `react-select` dropdown.
|
||||||
|
* @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
|
||||||
|
* @param {Matcher|Matcher[]} optionOrOptions The display name(s) for the option(s) to select
|
||||||
|
* @param {Object} config Optional config options
|
||||||
|
* @param {HTMLElement | (() => HTMLElement)} config.container A container for the react-select and its dropdown (defaults to the react-select container)
|
||||||
|
* Useful when rending the dropdown to a portal using react-select's `menuPortalTarget`.
|
||||||
|
* Can be specified as a function if it needs to be lazily evaluated.
|
||||||
|
*/
|
||||||
|
export async function select(
|
||||||
|
input: HTMLElement,
|
||||||
|
optionOrOptions: Matcher | Array<Matcher>,
|
||||||
|
{ user = userEvent, ...config }: Config = {}
|
||||||
|
) {
|
||||||
|
const options = Array.isArray(optionOrOptions)
|
||||||
|
? optionOrOptions
|
||||||
|
: [optionOrOptions];
|
||||||
|
|
||||||
|
// Select the items we care about
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const option of options) {
|
||||||
|
await openMenu(input, { user });
|
||||||
|
|
||||||
|
let container;
|
||||||
|
if (typeof config.container === 'function') {
|
||||||
|
// when specified as a function, the container needs to be lazily evaluated, so
|
||||||
|
// we have to wait for it to be visible:
|
||||||
|
await waitFor(config.container);
|
||||||
|
container = config.container();
|
||||||
|
} else if (config.container) {
|
||||||
|
container = config.container;
|
||||||
|
} else {
|
||||||
|
container = getReactSelectContainerFromInput(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only consider visible, interactive elements
|
||||||
|
const matchingElements = await findAllByText(container, option, {
|
||||||
|
ignore: "[aria-live] *,[style*='visibility: hidden']",
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the target option is already selected, the react-select display text
|
||||||
|
// will also match the selector. In this case, the actual dropdown element is
|
||||||
|
// positioned last in the DOM tree.
|
||||||
|
const optionElement = matchingElements[matchingElements.length - 1];
|
||||||
|
await user.click(optionElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateConfig extends Config, UserEventOptions {
|
||||||
|
createOptionText?: string | RegExp;
|
||||||
|
waitForElement?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Utility for creating and selecting a value in a Creatable `react-select` dropdown.
|
||||||
|
* @async
|
||||||
|
* @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
|
||||||
|
* @param {String} option The display name for the option to type and select
|
||||||
|
* @param {Object} config Optional config options
|
||||||
|
* @param {HTMLElement} config.container A container for the react-select and its dropdown (defaults to the react-select container)
|
||||||
|
* Useful when rending the dropdown to a portal using react-select's `menuPortalTarget`
|
||||||
|
* @param {boolean} config.waitForElement Whether create should wait for new option to be populated in the select container
|
||||||
|
* @param {String|RegExp} config.createOptionText Custom label for the "create new ..." option in the menu (string or regexp)
|
||||||
|
*/
|
||||||
|
export async function create(
|
||||||
|
input: HTMLElement,
|
||||||
|
option: string,
|
||||||
|
{ waitForElement = true, user = userEvent, ...config }: CreateConfig = {}
|
||||||
|
) {
|
||||||
|
const createOptionText = config.createOptionText || /^Create "/;
|
||||||
|
await openMenu(input, { user });
|
||||||
|
await type(input, option, { user });
|
||||||
|
|
||||||
|
await select(input, createOptionText, { ...config, user });
|
||||||
|
|
||||||
|
if (waitForElement) {
|
||||||
|
await findByText(getReactSelectContainerFromInput(input), option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for clearing the first value of a `react-select` dropdown.
|
||||||
|
* @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
|
||||||
|
*/
|
||||||
|
export async function clearFirst(
|
||||||
|
input: HTMLElement,
|
||||||
|
{ user = userEvent }: UserEventOptions = {}
|
||||||
|
) {
|
||||||
|
const container = getReactSelectContainerFromInput(input);
|
||||||
|
// The "clear" button is the first svg element that is hidden to screen readers
|
||||||
|
const clearButton = container.querySelector('svg[aria-hidden="true"]')!;
|
||||||
|
await clear(clearButton, { user });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for clearing all values in a `react-select` dropdown.
|
||||||
|
* @param {HTMLElement} input The input field (eg. `getByLabelText('The label')`)
|
||||||
|
*/
|
||||||
|
export async function clearAll(
|
||||||
|
input: HTMLElement,
|
||||||
|
{ user = userEvent }: UserEventOptions = {}
|
||||||
|
) {
|
||||||
|
const container = getReactSelectContainerFromInput(input);
|
||||||
|
// The "clear all" button is the penultimate svg element that is hidden to screen readers
|
||||||
|
// (the last one is the dropdown arrow)
|
||||||
|
const elements = container.querySelectorAll('svg[aria-hidden="true"]');
|
||||||
|
const clearAllButton = elements[elements.length - 2];
|
||||||
|
await clear(clearAllButton, { user });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(user: User): typeof selectEvent {
|
||||||
|
return {
|
||||||
|
select: (...params: Parameters<typeof select>) =>
|
||||||
|
select(params[0], params[1], { user, ...params[2] }),
|
||||||
|
create: (...params: Parameters<typeof create>) =>
|
||||||
|
create(params[0], params[1], { user, ...params[2] }),
|
||||||
|
clearFirst: (...params: Parameters<typeof clearFirst>) =>
|
||||||
|
clearFirst(params[0], { user, ...params[1] }),
|
||||||
|
clearAll: (...params: Parameters<typeof clearAll>) =>
|
||||||
|
clearAll(params[0], { user, ...params[1] }),
|
||||||
|
openMenu: (...params: Parameters<typeof openMenu>) =>
|
||||||
|
openMenu(params[0], { user, ...params[1] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectEvent = { select, create, clearFirst, clearAll, openMenu };
|
||||||
|
export default { ...selectEvent, setup };
|
|
@ -1,6 +1,10 @@
|
||||||
import { server } from './server';
|
import { server } from './server';
|
||||||
|
|
||||||
beforeAll(() => server.listen());
|
beforeAll(() =>
|
||||||
|
server.listen({
|
||||||
|
onUnhandledRequest: 'error',
|
||||||
|
})
|
||||||
|
);
|
||||||
// if you need to add a handler after calling setupServer for some specific test
|
// if you need to add a handler after calling setupServer for some specific test
|
||||||
// this will remove that handler for the rest of them
|
// this will remove that handler for the rest of them
|
||||||
// (which is important for test isolation):
|
// (which is important for test isolation):
|
||||||
|
|
Loading…
Reference in New Issue