fix(edge/templates): fix issues [EE-6328] (#10656)
parent
140ac5d17c
commit
76bcdfa2b8
|
@ -103,5 +103,5 @@ export const componentsModule = angular
|
|||
)
|
||||
.component(
|
||||
'edgeStackCreateTemplateFieldset',
|
||||
r2a(withReactQuery(TemplateFieldset), ['onChange', 'value', 'onChangeFile'])
|
||||
r2a(withReactQuery(TemplateFieldset), ['setValues', 'values', 'errors'])
|
||||
).name;
|
||||
|
|
|
@ -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<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} 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() {
|
||||
|
|
|
@ -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"
|
||||
></edge-stacks-docker-compose-form>
|
||||
|
||||
<edge-stacks-kube-manifest-form
|
||||
|
|
|
@ -3,16 +3,12 @@
|
|||
|
||||
<!-- template -->
|
||||
<div ng-if="$ctrl.state.Method === 'template'">
|
||||
<edge-stack-create-template-fieldset
|
||||
value="$ctrl.template"
|
||||
on-change="($ctrl.onChangeTemplate)"
|
||||
on-change-file="($ctrl.onChangeFileContent)"
|
||||
></edge-stack-create-template-fieldset>
|
||||
<edge-stack-create-template-fieldset values="$ctrl.templateValues" set-values="$ctrl.setTemplateValues"></edge-stack-create-template-fieldset>
|
||||
</div>
|
||||
<!-- !template -->
|
||||
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.Method === 'editor' || ($ctrl.state.Method === 'template' && $ctrl.template)"
|
||||
ng-if="$ctrl.state.Method === 'editor' || ($ctrl.state.Method === 'template' && $ctrl.templateValues.template)"
|
||||
identifier="stack-creation-editor"
|
||||
value="$ctrl.formValues.StackFileContent"
|
||||
on-change="($ctrl.onChangeFileContent)"
|
||||
|
|
|
@ -7,7 +7,7 @@ export const edgeStacksDockerComposeForm = {
|
|||
bindings: {
|
||||
formValues: '=',
|
||||
state: '=',
|
||||
template: '<',
|
||||
onChangeTemplate: '<',
|
||||
templateValues: '<',
|
||||
setTemplateValues: '<',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import { json2formData } from './axios';
|
||||
|
||||
describe('json2formData', () => {
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -159,12 +159,22 @@ export function json2formData(json: Record<string, unknown>) {
|
|||
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;
|
||||
|
|
|
@ -16,9 +16,9 @@ export function RadioGroup<T extends string | number = string>({
|
|||
return (
|
||||
<div>
|
||||
{options.map((option) => (
|
||||
<span
|
||||
<label
|
||||
key={option.value}
|
||||
className="col-sm-3 col-lg-2 control-label !p-0 text-left"
|
||||
className="col-sm-3 col-lg-2 control-label !p-0 text-left font-normal"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
@ -29,7 +29,7 @@ export function RadioGroup<T extends string | number = string>({
|
|||
style={{ margin: '0 4px 0 0' }}
|
||||
/>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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: Values;
|
||||
setValues: (values: SetStateAction<Values>) => 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<VariablesFieldValue>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateSelector
|
||||
value={selectedTemplate?.Id}
|
||||
onChange={handleChangeTemplate}
|
||||
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 || []
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{selectedTemplate && (
|
||||
{values.template && (
|
||||
<>
|
||||
{selectedTemplate.Note && (
|
||||
{values.template.Note && (
|
||||
<div>
|
||||
<div className="col-sm-12 form-section-title"> Information </div>
|
||||
<div className="form-group">
|
||||
|
@ -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),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -59,59 +81,34 @@ export function TemplateFieldset({
|
|||
|
||||
<CustomTemplatesVariablesField
|
||||
onChange={(value) => {
|
||||
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<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({
|
||||
select: (templates) =>
|
||||
|
@ -123,7 +120,7 @@ function TemplateSelector({
|
|||
}
|
||||
|
||||
return (
|
||||
<FormControl label="Template" inputId="stack_template">
|
||||
<FormControl label="Template" inputId="stack_template" errors={error}>
|
||||
<PortainerSelect
|
||||
placeholder="Select an Edge stack template"
|
||||
value={value}
|
||||
|
|
|
@ -62,7 +62,6 @@ export function PrivateRegistryFieldsetWrapper({
|
|||
const registries = await dryRunMutation.mutateAsync(values);
|
||||
|
||||
if (registries.length === 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -40,6 +40,12 @@ export function PrivateRegistryFieldset({
|
|||
const tooltipMessage =
|
||||
'This allows you to provide credentials when using a private registry that requires authentication';
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setChecked(isActive);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checked) {
|
||||
onChange();
|
||||
|
|
|
@ -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<Array<RegistryId>>(
|
||||
|
|
Loading…
Reference in New Issue