From fe6ed55cabc0af10ffb74279054b5a002c637a0f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 15 Feb 2024 09:00:57 +0200 Subject: [PATCH] feat(edge/stacks): add app templates to deploy types [EE-6632] (#11070) --- app/edge/__module.js | 2 +- app/edge/react/components/index.ts | 2 +- .../create-edge-stack-view.controller.js | 145 +++++++++---- .../docker-compose-form.controller.js | 2 +- .../components/custom-templates/index.ts | 1 + .../common-options/build-methods.tsx | 2 +- .../CreateView/TemplateFieldset.tsx | 149 ------------- .../AppTemplateFieldset.test.tsx | 55 +++++ .../TemplateFieldset/AppTemplateFieldset.tsx | 30 +++ .../CustomTemplateFieldset.tsx | 32 +++ .../TemplateFieldset/EnvVarsFieldset.test.tsx | 118 +++++++++++ .../TemplateFieldset}/EnvVarsFieldset.tsx | 22 +- .../TemplateFieldset/TemplateFieldset.tsx | 114 ++++++++++ .../TemplateFieldset/TemplateNote.test.tsx | 26 +++ .../TemplateFieldset/TemplateNote.tsx | 23 ++ .../TemplateSelector.test.tsx | 149 +++++++++++++ .../TemplateFieldset/TemplateSelector.tsx | 139 +++++++++++++ .../CreateView/TemplateFieldset/types.ts | 14 ++ .../TemplateFieldset/validation.tsx | 32 +++ .../AppTemplatesView/AppTemplatesView.tsx | 25 +-- .../templates/AppTemplatesView/DeployForm.tsx | 196 ------------------ .../custom-templates/ListView/ListView.tsx | 2 +- .../CustomTemplatesVariablesField.test.tsx | 98 +++++++++ .../CustomTemplatesVariablesField.tsx | 60 +----- .../VariableFieldItem.test.tsx | 53 +++++ .../VariableFieldItem.tsx | 38 ++++ .../CustomTemplatesVariablesField/index.ts | 3 +- .../validation.tsx | 23 ++ .../app-templates/AppTemplatesList.tsx | 8 +- .../app-templates/AppTemplatesListItem.tsx | 7 +- .../app-templates/queries/useAppTemplates.ts | 9 +- .../templates/app-templates/view-model.ts | 2 +- app/react/test-utils/react-select.ts | 188 +++++++++++++++++ app/setup-tests/setup-msw.ts | 6 +- 34 files changed, 1293 insertions(+), 482 deletions(-) delete mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx rename app/react/edge/{templates/AppTemplatesView => edge-stacks/CreateView/TemplateFieldset}/EnvVarsFieldset.tsx (69%) create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts create mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx delete mode 100644 app/react/edge/templates/AppTemplatesView/DeployForm.tsx create mode 100644 app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx create mode 100644 app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx create mode 100644 app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx create mode 100644 app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx create mode 100644 app/react/test-utils/react-select.ts diff --git a/app/edge/__module.js b/app/edge/__module.js index 0a4b416a6..33237de70 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -62,7 +62,7 @@ angular const stacksNew = { name: 'edge.stacks.new', - url: '/new?templateId', + url: '/new?templateId&templateType', views: { 'content@': { component: 'createEdgeStackView', diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 0835e50c3..b3d0eceb4 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; 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 .module('portainer.edge.react.components', []) diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js index 801e63306..a3969ae35 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js @@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types'; import { applySetStateAction } from '@/react-tools/apply-set-state-action'; import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; 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 { /* @ngInject */ @@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController { } /** - * @param {import('react').SetStateAction} templateAction + * @param {import('react').SetStateAction} templateAction */ setTemplateValues(templateAction) { return this.$async(async () => { @@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController { const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id; this.state.templateValues = newTemplateValues; if (newTemplateId !== oldTemplateId) { - await this.onChangeTemplate(newTemplateValues.template); + await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template); } - let definitions = []; - if (this.state.templateValues.template) { - definitions = this.state.templateValues.template.Variables; - } - const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions); + if (newTemplateValues.type === 'custom') { + const definitions = this.state.templateValues.template.Variables; + 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 () => { if (!template) { return; } - this.state.templateValues.template = template; - this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables); + if (type === 'custom') { + 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.state.templateValues.file = fileContent; + this.formValues = { + ...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 = { - ...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, - } - : {}), - }; + if (type === 'app') { + this.formValues.StackFileContent = ''; + try { + const fileContent = await fetchFilePreview(template.Id); + this.formValues.StackFileContent = fileContent; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve Template'); + } + } }); } @@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController { } } - async preSelectTemplate(templateId) { + /** + * + * @param {'app' | 'custom'} templateType + * @param {number} templateId + * @returns {Promise} + */ + async preSelectTemplate(templateType, templateId) { return this.$async(async () => { try { 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) { notifyError('Failed loading template', e); } @@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController { this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); } - const templateId = this.$state.params.templateId; - if (templateId) { - this.preSelectTemplate(templateId); + const templateId = parseInt(this.$state.params.templateId, 10); + const templateType = this.$state.params.templateType; + if (templateType && templateId && !Number.isNaN(templateId)) { + this.preSelectTemplate(templateType, templateId); } this.$window.onbeforeunload = () => { @@ -198,6 +225,12 @@ export default class CreateEdgeStackViewController { createStack() { return this.$async(async () => { 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); if (!this.validateForm(method)) { @@ -206,7 +239,7 @@ export default class CreateEdgeStackViewController { this.state.actionInProgress = true; try { - await this.createStackByMethod(name, method); + await this.createStackByMethod(name, method, envVars); this.Notifications.success('Success', 'Stack successfully deployed'); this.state.isEditorDirty = false; @@ -258,19 +291,19 @@ export default class CreateEdgeStackViewController { return true; } - createStackByMethod(name, method) { + createStackByMethod(name, method, envVars) { switch (method) { case 'editor': - return this.createStackFromFileContent(name); + return this.createStackFromFileContent(name, envVars); case 'upload': - return this.createStackFromFileUpload(name); + return this.createStackFromFileUpload(name, envVars); case 'repository': - return this.createStackFromGitRepository(name); + return this.createStackFromGitRepository(name, envVars); } } - createStackFromFileContent(name) { - const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; + createStackFromFileContent(name, envVars) { + const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues; return this.EdgeStackService.createStackFromFileContent({ name, @@ -282,8 +315,9 @@ export default class CreateEdgeStackViewController { }); } - createStackFromFileUpload(name) { - const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; + createStackFromFileUpload(name, envVars) { + const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues; + return this.EdgeStackService.createStackFromFileUpload( { Name: name, @@ -296,8 +330,9 @@ export default class CreateEdgeStackViewController { ); } - createStackFromGitRepository(name) { - const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; + async createStackFromGitRepository(name, envVars) { + const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues; + const repositoryOptions = { RepositoryURL: this.formValues.RepositoryURL, RepositoryReferenceName: this.formValues.RepositoryReferenceName, @@ -354,3 +389,25 @@ function getMethod(method, template) { } return 'editor'; } + +/** + * + * @param {'app' | 'custom'} templateType + * @param {number} templateId + * @returns {Promise} + */ +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; +} diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js index 99b132f97..0403d9f3f 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js @@ -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'; class DockerComposeFormController { diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts index 5730e7131..790e1dd27 100644 --- a/app/portainer/react/components/custom-templates/index.ts +++ b/app/portainer/react/components/custom-templates/index.ts @@ -48,6 +48,7 @@ export const ngModule = angular 'disabledTypes', 'fixedCategories', 'storageKey', + 'templateLinkParams', ]) ) .component( diff --git a/app/react/components/BoxSelector/common-options/build-methods.tsx b/app/react/components/BoxSelector/common-options/build-methods.tsx index b703b8444..678a45c6d 100644 --- a/app/react/components/BoxSelector/common-options/build-methods.tsx +++ b/app/react/components/BoxSelector/common-options/build-methods.tsx @@ -37,7 +37,7 @@ export const edgeStackTemplate: BoxSelectorOption<'template'> = { icon: FileText, iconType: 'badge', label: 'Template', - description: 'Use an Edge stack template', + description: 'Use an Edge stack app or custom template', value: 'template', }; diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx deleted file mode 100644 index 7015a6709..000000000 --- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx +++ /dev/null @@ -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; - setValues: (values: SetStateAction) => 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 ( - <> - { - setValues((values) => { - const template = templatesQuery.data?.find( - (template) => template.Id === value - ); - return { - ...values, - template, - variables: getVariablesFieldDefaultValues( - template?.Variables || [] - ), - }; - }); - }} - /> - {values.template && ( - <> - {values.template.Note && ( -
-
Information
-
-
-
-
-
-
- )} - - { - setValues((values) => ({ - ...values, - variables: value, - })); - }} - value={values.variables} - definitions={values.template.Variables} - errors={errors?.variables} - /> - - )} - - ); - - function setValues(values: SetStateAction) { - 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 ( - - ({ - label: `${template.Title} - ${template.Description}`, - value: template.Id, - }))} - /> - - ); - - function handleChange(value: CustomTemplate['Id']) { - onChange(value); - } -} - -export function getInitialTemplateValues() { - return { - template: null, - variables: [], - file: '', - }; -} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx new file mode 100644 index 000000000..228abca60 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.test.tsx @@ -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 = { + VAR1: 'value1', + VAR2: 'value2', + }; + + const onChange = vi.fn(); + + render( + + ); + + const templateNoteElement = screen.getByText('This is a template note'); + expect(templateNoteElement).toBeInTheDocument(); + + const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, { + exact: false, + }); + expect(envVarsFieldsetElement).toBeInTheDocument(); +}); diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx new file mode 100644 index 000000000..184aeb614 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx @@ -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; + onChange: (value: Record) => void; + errors?: FormikErrors>; +}) { + return ( + <> + + + + ); +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx new file mode 100644 index 000000000..12bc0c847 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx @@ -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 | undefined; + template: CustomTemplate; +}) { + return ( + <> + + + + + ); +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx new file mode 100644 index 000000000..d90af69eb --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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); +}); diff --git a/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx similarity index 69% rename from app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx rename to app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx index 917bdc7ef..715e49320 100644 --- a/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx @@ -1,4 +1,5 @@ import { FormikErrors } from 'formik'; +import { SchemaOf, array, string } from 'yup'; import { TemplateEnv } from '@/react/portainer/templates/app-templates/types'; @@ -20,13 +21,13 @@ export function EnvVarsFieldset({ }) { return ( <> - {options.map((env, index) => ( + {options.map((env) => ( handleChange(env.name, value)} - errors={errors?.[index]} + errors={errors?.[env.name]} /> ))} @@ -48,11 +49,13 @@ function Item({ onChange: (value: string) => void; errors?: FormikErrors; }) { + const inputId = `env_var_${option.name}`; return ( {option.select ? ( onChange(e.target.value)} disabled={option.preset} + id={inputId} /> )} ); } + +export function getDefaultValues(definitions: Array): Value { + return Object.fromEntries(definitions.map((v) => [v.name, v.default || ''])); +} + +export function envVarsFieldsetValidation(): SchemaOf { + 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 + ); +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx new file mode 100644 index 000000000..a48991558 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx @@ -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; + setValues: (values: SetStateAction) => 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 ( + <> + + {values.template && ( + <> + {values.type === 'custom' && ( + + setValues((values) => ({ ...values, variables })) + } + errors={errors?.variables} + /> + )} + + {values.type === 'app' && ( + + setValues((values) => ({ ...values, envVars })) + } + errors={errors?.envVars} + /> + )} + + )} + + ); + + function setValues(values: SetStateAction) { + 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: {}, + }; +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx new file mode 100644 index 000000000..32f3d2ac1 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx @@ -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(); + + 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(); + + const templateNoteElement = screen.queryByText(/Information/); + expect(templateNoteElement).not.toBeInTheDocument(); +}); diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx new file mode 100644 index 000000000..49ead6eb3 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx @@ -0,0 +1,23 @@ +import sanitize from 'sanitize-html'; + +export function TemplateNote({ note }: { note: string | undefined }) { + if (!note) { + return null; + } + return ( +
+
Information
+
+
+
+
+
+
+ ); +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx new file mode 100644 index 000000000..c68a1fb42 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.test.tsx @@ -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>; + customTemplates?: Array>; + 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( + + ); + + 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, + }); + } +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx new file mode 100644 index 000000000..76f348a3d --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateSelector.tsx @@ -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 ( + + { + 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} + /> + + ); +} + +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) { + return ( + + {label} + + ); +} diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts new file mode 100644 index 000000000..4f8273e11 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/types.ts @@ -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; +} & SelectedTemplateValue; diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx new file mode 100644 index 000000000..eeabded15 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx @@ -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) => 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 }; diff --git a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx index 86f47efb0..8c6a84785 100644 --- a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx +++ b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx @@ -1,37 +1,22 @@ -import { useParamState } from '@/react/hooks/useParamState'; import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList'; import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates'; import { TemplateType } from '@/react/portainer/templates/app-templates/types'; import { PageHeader } from '@@/PageHeader'; -import { DeployFormWidget } from './DeployForm'; - export function AppTemplatesView() { - const [selectedTemplateId, setSelectedTemplateId] = useParamState( - 'template', - (param) => (param ? parseInt(param, 10) : 0) - ); const templatesQuery = useAppTemplates(); - const selectedTemplate = selectedTemplateId - ? templatesQuery.data?.find( - (template) => template.Id === selectedTemplateId - ) - : undefined; + return ( <> - {selectedTemplate && ( - setSelectedTemplateId()} - /> - )} setSelectedTemplateId(template.Id)} + templateLinkParams={(template) => ({ + to: 'edge.stacks.new', + params: { templateId: template.Id, templateType: 'app' }, + })} disabledTypes={[TemplateType.Container]} fixedCategories={['edge']} storageKey="edge-app-templates" diff --git a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx deleted file mode 100644 index a0121a6f7..000000000 --- a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx +++ /dev/null @@ -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 ( -
-
- - } - /> - } - title={template.Title} - /> - - - - -
-
- ); -} - -interface FormValues { - name: string; - edgeGroupIds: Array; - envVars: Record; -} - -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 ( - - validation(edgeStacksQuery.data, edgeGroupsQuery.data) - } - validateOnMount - > - {({ values, errors, setFieldValue, isValid }) => ( -
- setFieldValue('name', v)} - errors={errors.name} - /> - - setFieldValue('edgeGroupIds', value)} - required - /> - - setFieldValue('envVars', values)} - /> - - - - - - )} -
- ); - - 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> -) { - 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]); - } -} diff --git a/app/react/edge/templates/custom-templates/ListView/ListView.tsx b/app/react/edge/templates/custom-templates/ListView/ListView.tsx index 2e1b83294..ef93cf78c 100644 --- a/app/react/edge/templates/custom-templates/ListView/ListView.tsx +++ b/app/react/edge/templates/custom-templates/ListView/ListView.tsx @@ -24,7 +24,7 @@ export function ListView() { onDelete={handleDelete} templateLinkParams={(template) => ({ to: 'edge.stacks.new', - params: { templateId: template.Id }, + params: { templateId: template.Id, templateType: 'custom' }, })} storageKey="edge-custom-templates" /> diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx new file mode 100644 index 000000000..084430e2c --- /dev/null +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + const errorElement = screen.getByText('Error message'); + expect(errorElement).toBeInTheDocument(); +}); diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx index 3e7d6ecc8..d5b408c03 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx @@ -1,13 +1,11 @@ -import { array, object, string } from 'yup'; - -import { FormControl } from '@@/form-components/FormControl'; import { FormSection } from '@@/form-components/FormSection/FormSection'; -import { Input } from '@@/form-components/Input'; import { ArrayError } from '@@/form-components/InputList/InputList'; import { FormError } from '@@/form-components/FormError'; import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; +import { VariableFieldItem } from './VariableFieldItem'; + export type Values = Array<{ key: string; value?: string }>; interface Props { @@ -33,8 +31,8 @@ export function CustomTemplatesVariablesField({ v.key === definition.name)?.value || ''} error={getError(errors, index)} + value={value.find((v) => v.key === definition.name)?.value} onChange={(fieldValue) => { onChange( 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 ( - - onChange(e.target.value)} - /> - - ); -} - function getError(errors: ArrayError | undefined, index: number) { if (!errors || typeof errors !== 'object') { return undefined; @@ -95,22 +60,3 @@ function getError(errors: ArrayError | undefined, index: number) { 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; - }) - ); -} diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx new file mode 100644 index 000000000..9b37bf6c2 --- /dev/null +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.test.tsx @@ -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(); + + 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( + + ); + + const inputElement = screen.getByLabelText(definition.label); + + await user.clear(inputElement); + expect(onChange).toHaveBeenCalledWith(''); + + await user.type(inputElement, 'New Value'); + expect(onChange).toHaveBeenCalled(); +}); diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx new file mode 100644 index 000000000..e4083f909 --- /dev/null +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/VariableFieldItem.tsx @@ -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 ( + + onChange(e.target.value)} + placeholder={`Enter value or leave blank to use default of ${definition.defaultValue}`} + /> + + ); +} diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts index 014903d90..9824f371f 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts @@ -1,7 +1,8 @@ export { CustomTemplatesVariablesField, type Values as VariablesFieldValue, - validation as variablesFieldValidation, } from './CustomTemplatesVariablesField'; +export { validation as variablesFieldValidation } from './validation'; + export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues'; diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx new file mode 100644 index 000000000..be4668d65 --- /dev/null +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/validation.tsx @@ -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; + }) + ); +} diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx index ab013e2d2..1184f34d6 100644 --- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx +++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx @@ -21,13 +21,18 @@ export function AppTemplatesList({ disabledTypes, fixedCategories, storageKey, + templateLinkParams, }: { storageKey: string; templates?: TemplateViewModel[]; - onSelect: (template: TemplateViewModel) => void; + onSelect?: (template: TemplateViewModel) => void; selectedId?: TemplateViewModel['Id']; disabledTypes?: Array; fixedCategories?: Array; + templateLinkParams?: (template: TemplateViewModel) => { + to: string; + params: object; + }; }) { const [page, setPage] = useState(0); const [store] = useState(() => @@ -88,6 +93,7 @@ export function AppTemplatesList({ template={template} onSelect={onSelect} isSelected={selectedId === template.Id} + linkParams={templateLinkParams?.(template)} /> ))} {!templates &&
Loading...
} diff --git a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx index 9e0abdc82..abba78ccf 100644 --- a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx +++ b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx @@ -12,10 +12,12 @@ export function AppTemplatesListItem({ template, onSelect, isSelected, + linkParams, }: { template: TemplateViewModel; - onSelect: (template: TemplateViewModel) => void; + onSelect?: (template: TemplateViewModel) => void; isSelected: boolean; + linkParams?: { to: string; params: object }; }) { const duplicateCustomTemplateType = getCustomTemplateType(template.Type); @@ -25,7 +27,8 @@ export function AppTemplatesListItem({ typeLabel={ template.Type === TemplateType.Container ? 'container' : 'stack' } - onSelect={() => onSelect(template)} + linkParams={linkParams} + onSelect={() => onSelect?.(template)} isSelected={isSelected} renderActions={ duplicateCustomTemplateType && ( diff --git a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts index cdc116463..589c761d0 100644 --- a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts +++ b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts @@ -10,7 +10,9 @@ import { TemplateViewModel } from '../view-model'; import { buildUrl } from './build-url'; -export function useAppTemplates() { +export function useAppTemplates>({ + select, +}: { select?: (templates: Array) => T } = {}) { const registriesQuery = useRegistries(); return useQuery( @@ -18,6 +20,7 @@ export function useAppTemplates() { () => getTemplatesWithRegistry(registriesQuery.data), { enabled: !!registriesQuery.data, + select, } ); } @@ -29,7 +32,7 @@ async function getTemplatesWithRegistry( return []; } - const { templates, version } = await getTemplates(); + const { templates, version } = await getAppTemplates(); return templates.map((item) => { const template = new TemplateViewModel(item, version); const registryURL = item.registry; @@ -41,7 +44,7 @@ async function getTemplatesWithRegistry( }); } -async function getTemplates() { +export async function getAppTemplates() { try { const { data } = await axios.get<{ version: string; diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts index 4468fc2b8..5714058c0 100644 --- a/app/react/portainer/templates/app-templates/view-model.ts +++ b/app/react/portainer/templates/app-templates/view-model.ts @@ -150,7 +150,7 @@ function templateVolumes(data: AppTemplate) { ); } -enum EnvVarType { +export enum EnvVarType { PreSelected = 1, Text = 2, Select = 3, diff --git a/app/react/test-utils/react-select.ts b/app/react/test-utils/react-select.ts new file mode 100644 index 000000000..43c6fed42 --- /dev/null +++ b/app/react/test-utils/react-select.ts @@ -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; + +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 +) { + await user.type(input, text); +} + +// press the "clear" button, and reset various states +async function clear( + clearButton: Element, + { user }: Required +) { + 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, + { 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) => + select(params[0], params[1], { user, ...params[2] }), + create: (...params: Parameters) => + create(params[0], params[1], { user, ...params[2] }), + clearFirst: (...params: Parameters) => + clearFirst(params[0], { user, ...params[1] }), + clearAll: (...params: Parameters) => + clearAll(params[0], { user, ...params[1] }), + openMenu: (...params: Parameters) => + openMenu(params[0], { user, ...params[1] }), + }; +} + +const selectEvent = { select, create, clearFirst, clearAll, openMenu }; +export default { ...selectEvent, setup }; diff --git a/app/setup-tests/setup-msw.ts b/app/setup-tests/setup-msw.ts index 4df7ca3c6..118196636 100644 --- a/app/setup-tests/setup-msw.ts +++ b/app/setup-tests/setup-msw.ts @@ -1,6 +1,10 @@ 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 // this will remove that handler for the rest of them // (which is important for test isolation):