diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index e7a1f8b92..54fba8757 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -103,5 +103,5 @@ export const componentsModule = angular ) .component( 'edgeStackCreateTemplateFieldset', - r2a(withReactQuery(TemplateFieldset), ['onChange', 'value', 'onChangeFile']) + r2a(withReactQuery(TemplateFieldset), ['setValues', 'values', 'errors']) ).name; 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 dfae4726b..4fb8a5abc 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 @@ -10,6 +10,9 @@ import { notifyError } from '@/portainer/services/notifications'; import { getCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; import { toGitFormModel } from '@/react/portainer/gitops/types'; 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'; export default class CreateEdgeStackViewController { /* @ngInject */ @@ -47,7 +50,11 @@ export default class CreateEdgeStackViewController { endpointTypes: [], baseWebhookUrl: baseEdgeStackWebhookUrl(), isEdit: false, - selectedTemplate: null, + templateValues: { + template: null, + variables: [], + file: '', + }, }; this.edgeGroups = null; @@ -64,15 +71,40 @@ export default class CreateEdgeStackViewController { this.hasType = this.hasType.bind(this); this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); this.onEnvVarChange = this.onEnvVarChange.bind(this); + this.setTemplateValues = this.setTemplateValues.bind(this); this.onChangeTemplate = this.onChangeTemplate.bind(this); } /** - * @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate} template + * @param {import('react').SetStateAction} templateAction */ + setTemplateValues(templateAction) { + return this.$async(async () => { + const newTemplateValues = applySetStateAction(templateAction, this.state.templateValues); + const oldTemplateId = this.state.templateValues.template && this.state.templateValues.template.Id; + const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id; + this.state.templateValues = newTemplateValues; + if (newTemplateId !== oldTemplateId) { + await this.onChangeTemplate(newTemplateValues.template); + } + + const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, this.state.templateValues.template.Variables); + + this.formValues.StackFileContent = newFile; + }); + } + onChangeTemplate(template) { - return this.$scope.$evalAsync(() => { - this.state.selectedTemplate = template; + return this.$async(async () => { + if (!template) { + return; + } + + this.state.templateValues.template = template; + this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables); + + const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig }); + this.state.templateValues.file = fileContent; this.formValues = { ...this.formValues, @@ -82,7 +114,7 @@ export default class CreateEdgeStackViewController { ? { PrePullImage: template.EdgeSettings.PrePullImage || false, RetryDeploy: template.EdgeSettings.RetryDeploy || false, - Registries: template.EdgeSettings.PrivateRegistryId ? [template.EdgeSettings.PrivateRegistryId] : [], + PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null, SupportRelativePath: template.EdgeSettings.RelativePathSettings.SupportRelativePath || false, FilesystemPath: template.EdgeSettings.RelativePathSettings.FilesystemPath || '', } @@ -128,15 +160,16 @@ export default class CreateEdgeStackViewController { } async preSelectTemplate(templateId) { - try { - this.state.Method = 'template'; - const template = await getCustomTemplate(templateId); - this.onChangeTemplate(template); - const fileContent = await getCustomTemplateFile({ id: templateId, git: !!template.GitConfig }); - this.formValues.StackFileContent = fileContent; - } catch (e) { - notifyError('Failed loading template', e); - } + return this.$async(async () => { + try { + this.state.Method = 'template'; + const template = await getCustomTemplate(templateId); + + this.setTemplateValues({ template }); + } catch (e) { + notifyError('Failed loading template', e); + } + }); } async $onInit() { diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index de7d96b08..c7a992d5e 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -57,8 +57,8 @@ ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose" form-values="$ctrl.formValues" state="$ctrl.state" - template="$ctrl.state.selectedTemplate" - on-change-template="($ctrl.onChangeTemplate)" + template-values="$ctrl.state.templateValues" + set-template-values="$ctrl.setTemplateValues" >
- +
{ + it('should handle undefined and null values', () => { + const json = { key1: undefined, key2: null }; + const formData = json2formData(json); + expect(formData.has('key1')).toBe(false); + expect(formData.has('key2')).toBe(false); + }); + + it('should handle File instances', () => { + const file = new File([''], 'filename'); + const json = { key: file }; + const formData = json2formData(json); + expect(formData.get('key')).toBe(file); + }); + + it('should handle arrays', () => { + const json = { key: [1, 2, 3] }; + const formData = json2formData(json); + expect(formData.get('key')).toBe('[1,2,3]'); + }); + + it('should handle objects', () => { + const json = { key: { subkey: 'value' } }; + const formData = json2formData(json); + expect(formData.get('key')).toBe('{"subkey":"value"}'); + }); + + it('should handle other types of values', () => { + const json = { key1: 'value', key2: 123, key3: true }; + const formData = json2formData(json); + expect(formData.get('key1')).toBe('value'); + expect(formData.get('key2')).toBe('123'); + expect(formData.get('key3')).toBe('true'); + }); + + it('should fail when handling circular references', () => { + const circularReference = { self: undefined }; + + // @ts-expect-error test + circularReference.self = circularReference; + const json = { key: circularReference }; + expect(() => json2formData(json)).toThrow(); + }); +}); diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index 4849970ce..bb83c1d86 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -159,12 +159,22 @@ export function json2formData(json: Record) { return; } + if (value instanceof File) { + formData.append(key, value); + return; + } + if (Array.isArray(value)) { formData.append(key, arrayToJson(value)); return; } - formData.append(key, value as string); + if (typeof value === 'object') { + formData.append(key, JSON.stringify(value)); + return; + } + + formData.append(key, value.toString()); }); return formData; diff --git a/app/react/components/RadioGroup/RadioGroup.tsx b/app/react/components/RadioGroup/RadioGroup.tsx index 83da291fc..f416bbc4a 100644 --- a/app/react/components/RadioGroup/RadioGroup.tsx +++ b/app/react/components/RadioGroup/RadioGroup.tsx @@ -16,9 +16,9 @@ export function RadioGroup({ return (
{options.map((option) => ( - ({ style={{ margin: '0 4px 0 0' }} /> {option.label} - + ))}
); diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx index b49c52713..508037a70 100644 --- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx @@ -1,46 +1,68 @@ -import { useState } from 'react'; +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 { useCustomTemplateFileMutation } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; import { CustomTemplatesVariablesField, VariablesFieldValue, getVariablesFieldDefaultValues, } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; -import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { FormControl } from '@@/form-components/FormControl'; import { PortainerSelect } from '@@/form-components/PortainerSelect'; +export interface Values { + template: CustomTemplate | undefined; + variables: VariablesFieldValue; +} + export function TemplateFieldset({ - value: selectedTemplate, - onChange, - onChangeFile, + values: initialValues, + setValues: setInitialValues, + errors, }: { - value: CustomTemplate | undefined; - onChange: (value?: CustomTemplate) => void; - onChangeFile: (value: string) => void; + errors?: FormikErrors; + values: Values; + setValues: (values: SetStateAction) => void; }) { - const fetchFileMutation = useCustomTemplateFileMutation(); - const [templateFile, setTemplateFile] = useState(''); + 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({ select: (templates) => templates.filter((template) => template.EdgeTemplate), }); - const [variableValues, setVariableValues] = useState([]); - return ( <> { + setValues((values) => { + const template = templatesQuery.data?.find( + (template) => template.Id === value + ); + return { + ...values, + template, + variables: getVariablesFieldDefaultValues( + template?.Variables || [] + ), + }; + }); + }} /> - {selectedTemplate && ( + {values.template && ( <> - {selectedTemplate.Note && ( + {values.template.Note && (
Information
@@ -49,7 +71,7 @@ export function TemplateFieldset({ className="template-note" // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ - __html: sanitize(selectedTemplate.Note), + __html: sanitize(values.template.Note), }} />
@@ -59,59 +81,34 @@ export function TemplateFieldset({ { - setVariableValues(value); - onChangeFile( - renderTemplate(templateFile, value, selectedTemplate.Variables) - ); + setValues((values) => ({ + ...values, + variables: value, + })); }} - value={variableValues} - definitions={selectedTemplate.Variables} + value={values.variables} + definitions={values.template.Variables} + errors={errors?.variables} /> )} ); - function handleChangeTemplate(templateId: CustomTemplate['Id'] | undefined) { - const selectedTemplate = templatesQuery.data?.find( - (template) => template.Id === templateId - ); - if (!selectedTemplate) { - setVariableValues([]); - onChange(undefined); - return; - } - - fetchFileMutation.mutate( - { id: selectedTemplate.Id, git: !!selectedTemplate.GitConfig }, - { - onSuccess: (data) => { - setTemplateFile(data); - onChangeFile( - renderTemplate( - data, - getVariablesFieldDefaultValues(selectedTemplate.Variables), - selectedTemplate.Variables - ) - ); - }, - } - ); - setVariableValues( - selectedTemplate - ? getVariablesFieldDefaultValues(selectedTemplate.Variables) - : [] - ); - onChange(selectedTemplate); + 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({ select: (templates) => @@ -123,7 +120,7 @@ function TemplateSelector({ } return ( - + { + if (isActive) { + setChecked(isActive); + } + }, [isActive]); + useEffect(() => { if (checked) { onChange(); diff --git a/app/react/edge/edge-stacks/queries/useParseRegistries.ts b/app/react/edge/edge-stacks/queries/useParseRegistries.ts index a4a49fa25..e13f2334e 100644 --- a/app/react/edge/edge-stacks/queries/useParseRegistries.ts +++ b/app/react/edge/edge-stacks/queries/useParseRegistries.ts @@ -15,17 +15,20 @@ export function useParseRegistries() { }); } -export async function parseRegistries(props: { +export async function parseRegistries({ + file, + fileContent, +}: { file?: File; fileContent?: string; }) { - if (!props.file && !props.fileContent) { + if (!file && !fileContent) { throw new Error('File or fileContent must be provided'); } - let currentFile = props.file; - if (!props.file && props.fileContent) { - currentFile = new File([props.fileContent], 'registries.yml'); + let currentFile = file; + if (!file && fileContent) { + currentFile = new File([fileContent], 'registries.yml'); } try { const { data } = await axios.post>(