mirror of https://github.com/portainer/portainer
fix(edge-stacks): various custom template issues [BE-11414] (#189)
parent
16a1825990
commit
97e7a3c5e2
|
@ -74,6 +74,10 @@ angular
|
|||
data: {
|
||||
docs: '/user/edge/stacks/add',
|
||||
},
|
||||
params: {
|
||||
templateId: { dynamic: true },
|
||||
templateType: { dynamic: true },
|
||||
},
|
||||
};
|
||||
|
||||
const stacksEdit = {
|
||||
|
|
|
@ -271,7 +271,7 @@
|
|||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
|
||||
ng-click="$ctrl.updateRegistry()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
|
|
|
@ -14,7 +14,7 @@ export function RadioGroup<T extends string | number = string>({
|
|||
onOptionChange,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
|
|
|
@ -1,186 +0,0 @@
|
|||
import { DefaultBodyType, HttpResponse } from 'msw';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { http, server } from '@/setup-tests/server';
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
|
||||
import { CreateForm } from './CreateForm';
|
||||
|
||||
// browser address
|
||||
// /edge/stacks/new?templateId=54&templateType=app
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { templateId: 54, templateType: 'app' },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@uiw/react-codemirror', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div />,
|
||||
}));
|
||||
|
||||
// app templates request
|
||||
// GET /api/templates
|
||||
const templatesResponseBody = {
|
||||
version: '3',
|
||||
templates: [
|
||||
{
|
||||
id: 54,
|
||||
type: 3,
|
||||
title: 'TOSIBOX Lock for Container',
|
||||
description:
|
||||
'Lock for Container brings secure connectivity inside your industrial IoT devices',
|
||||
administrator_only: false,
|
||||
image: '',
|
||||
repository: {
|
||||
url: 'https://github.com/portainer/templates',
|
||||
stackfile: 'stacks/tosibox/docker-compose.yml',
|
||||
},
|
||||
stackFile: '',
|
||||
logo: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/tosibox.png',
|
||||
env: [
|
||||
{
|
||||
name: 'LICENSE_KEY',
|
||||
label: 'License key',
|
||||
},
|
||||
],
|
||||
platform: 'linux',
|
||||
categories: ['edge'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// app template content request
|
||||
// GET /api/templates/54/file
|
||||
const templateContentResponseBody = {
|
||||
FileContent:
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
|
||||
};
|
||||
|
||||
// edge groups
|
||||
const edgeGroups = [
|
||||
{
|
||||
Id: 1,
|
||||
Name: 'docker',
|
||||
Dynamic: false,
|
||||
TagIds: [],
|
||||
Endpoints: [12],
|
||||
PartialMatch: false,
|
||||
HasEdgeStack: false,
|
||||
HasEdgeJob: false,
|
||||
EndpointTypes: [4],
|
||||
TrustedEndpoints: [12],
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: 'kubernetes',
|
||||
Dynamic: false,
|
||||
TagIds: [],
|
||||
Endpoints: [11],
|
||||
PartialMatch: false,
|
||||
HasEdgeStack: false,
|
||||
HasEdgeJob: false,
|
||||
EndpointTypes: [7],
|
||||
TrustedEndpoints: [11],
|
||||
},
|
||||
];
|
||||
|
||||
// expected form values
|
||||
const expectedPayload = {
|
||||
deploymentType: 0,
|
||||
edgeGroups: [1],
|
||||
name: 'my-stack',
|
||||
envVars: [{ name: 'LICENSE_KEY', value: 'license-123' }],
|
||||
prePullImage: false,
|
||||
registries: [],
|
||||
retryDeploy: false,
|
||||
staggerConfig: {
|
||||
StaggerOption: 1,
|
||||
StaggerParallelOption: 1,
|
||||
DeviceNumber: 1,
|
||||
DeviceNumberStartFrom: 0,
|
||||
DeviceNumberIncrementBy: 2,
|
||||
Timeout: '',
|
||||
UpdateDelay: '',
|
||||
UpdateFailureAction: 1,
|
||||
},
|
||||
useManifestNamespaces: false,
|
||||
stackFileContent:
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
|
||||
};
|
||||
|
||||
function renderCreateForm() {
|
||||
server.use(
|
||||
http.get('/api/templates', () => HttpResponse.json(templatesResponseBody))
|
||||
);
|
||||
server.use(
|
||||
http.post('/api/templates/54/file', () =>
|
||||
HttpResponse.json(templateContentResponseBody)
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
|
||||
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
|
||||
server.use(http.get('/api/registries', () => HttpResponse.json([])));
|
||||
server.use(http.get('/api/custom_templates', () => HttpResponse.json([])));
|
||||
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(CreateForm), user)
|
||||
);
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
test('The web editor should be visible for app templates', async () => {
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
// Wait for the form to be rendered
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// the web editor should be visible
|
||||
expect(getByLabelText('Web editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('The form should submit the correct request body', async () => {
|
||||
let requestBody: DefaultBodyType;
|
||||
|
||||
server.use(
|
||||
http.post('/api/edge_stacks/create/string', async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// fill in the name and select the docker edge group
|
||||
const user = userEvent.setup();
|
||||
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
|
||||
await user.type(
|
||||
getByRole('textbox', { name: 'License key *' }),
|
||||
'license-123'
|
||||
);
|
||||
const selectElement = getByLabelText('Edge groups');
|
||||
await selectEvent.select(selectElement, 'docker');
|
||||
|
||||
// submit the form
|
||||
await user.click(getByRole('button', { name: /Deploy the stack/i }));
|
||||
|
||||
// verify the request body
|
||||
await waitFor(() => {
|
||||
expect(requestBody).toEqual(expectedPayload);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
|
||||
|
@ -18,12 +18,12 @@ import { DeploymentType } from '../types';
|
|||
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||
|
||||
import { InnerForm } from './InnerForm';
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './CreateForm.validation';
|
||||
import { Values as TemplateValues } from './TemplateFieldset/types';
|
||||
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
|
||||
import { useTemplateParams } from './useTemplateParams';
|
||||
import { useCreate } from './useCreate';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function CreateForm() {
|
||||
const [webhookId] = useState(() => createWebhookId());
|
||||
|
@ -38,33 +38,12 @@ export function CreateForm() {
|
|||
templateType: templateParams.type,
|
||||
});
|
||||
|
||||
if (
|
||||
templateParams.id &&
|
||||
!(templateQuery.customTemplate || templateQuery.appTemplate)
|
||||
) {
|
||||
const initialValues = useInitialValues(templateQuery, templateParams);
|
||||
|
||||
if (!initialValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
deploymentType: DeploymentType.Compose,
|
||||
envVars: [],
|
||||
privateRegistryId: 0,
|
||||
prePullImage: false,
|
||||
retryDeploy: false,
|
||||
staggerConfig: getDefaultStaggerConfig(),
|
||||
method: templateParams.id ? 'template' : 'editor',
|
||||
git: toGitFormModel(undefined, parseAutoUpdateResponse()),
|
||||
relativePath: getDefaultRelativePathModel(),
|
||||
enableWebhook: false,
|
||||
fileContent: '',
|
||||
templateValues: getTemplateValues(templateParams.type, template),
|
||||
useManifestNamespaces: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
|
@ -128,7 +107,66 @@ function useTemplate(
|
|||
});
|
||||
|
||||
return {
|
||||
appTemplate: appTemplateQuery.data,
|
||||
customTemplate: customTemplateQuery.data,
|
||||
appTemplate: type === 'app' ? appTemplateQuery.data : undefined,
|
||||
customTemplate: type === 'custom' ? customTemplateQuery.data : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function useInitialValues(
|
||||
templateQuery: {
|
||||
appTemplate: TemplateViewModel | undefined;
|
||||
customTemplate: CustomTemplate | undefined;
|
||||
},
|
||||
templateParams: {
|
||||
id: number | undefined;
|
||||
type: 'app' | 'custom' | undefined;
|
||||
}
|
||||
) {
|
||||
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||
const initialValues: FormValues = useMemo(
|
||||
() => ({
|
||||
name: '',
|
||||
groupIds: [],
|
||||
// if edge custom templates allow kube manifests/helm charts in future, add logic for setting this for the initial deploymentType
|
||||
deploymentType: DeploymentType.Compose,
|
||||
envVars: [],
|
||||
privateRegistryId:
|
||||
templateQuery.customTemplate?.EdgeSettings?.PrivateRegistryId ?? 0,
|
||||
prePullImage:
|
||||
templateQuery.customTemplate?.EdgeSettings?.PrePullImage ?? false,
|
||||
retryDeploy:
|
||||
templateQuery.customTemplate?.EdgeSettings?.RetryDeploy ?? false,
|
||||
staggerConfig:
|
||||
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
|
||||
getDefaultStaggerConfig(),
|
||||
method: templateParams.id ? 'template' : 'editor',
|
||||
git: toGitFormModel(
|
||||
templateQuery.customTemplate?.GitConfig,
|
||||
parseAutoUpdateResponse()
|
||||
),
|
||||
relativePath:
|
||||
templateQuery.customTemplate?.EdgeSettings?.RelativePathSettings ??
|
||||
getDefaultRelativePathModel(),
|
||||
enableWebhook: false,
|
||||
fileContent: '',
|
||||
templateValues: getTemplateValues(templateParams.type, template),
|
||||
useManifestNamespaces: false,
|
||||
}),
|
||||
[
|
||||
templateQuery.customTemplate,
|
||||
templateParams.id,
|
||||
templateParams.type,
|
||||
template,
|
||||
]
|
||||
);
|
||||
|
||||
if (
|
||||
templateParams.id &&
|
||||
!templateQuery.customTemplate &&
|
||||
!templateQuery.appTemplate
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return initialValues;
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ export function useValidation({
|
|||
}),
|
||||
templateValues: templateFieldsetValidation({
|
||||
customVariablesDefinitions: customTemplate?.Variables || [],
|
||||
envVarDefinitions: appTemplate?.Env || [],
|
||||
appTemplateVariablesDefinitions: appTemplate?.Env || [],
|
||||
}),
|
||||
git: mixed().when('method', {
|
||||
is: 'repository',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { FormikErrors, useFormikContext } from 'formik';
|
||||
import { SetStateAction } from 'react';
|
||||
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
|
@ -24,31 +25,18 @@ import { useRenderAppTemplate } from './useRenderAppTemplate';
|
|||
|
||||
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||
|
||||
export function DockerComposeForm({
|
||||
webhookId,
|
||||
onChangeTemplate,
|
||||
}: {
|
||||
interface Props {
|
||||
webhookId: string;
|
||||
onChangeTemplate: ({
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
onChangeTemplate: (change: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
}) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||
const { method } = values;
|
||||
|
||||
const { customTemplate, isInitialLoading: isCustomTemplateLoading } =
|
||||
useRenderCustomTemplate(values.templateValues, setValues);
|
||||
const { appTemplate, isInitialLoading: isAppTemplateLoading } =
|
||||
useRenderAppTemplate(values.templateValues, setValues);
|
||||
|
||||
const isTemplate =
|
||||
method === edgeStackTemplate.value && (customTemplate || appTemplate);
|
||||
const isTemplateLoading = isCustomTemplateLoading || isAppTemplateLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection title="Build Method">
|
||||
|
@ -62,10 +50,10 @@ export function DockerComposeForm({
|
|||
</FormSection>
|
||||
|
||||
{method === edgeStackTemplate.value && (
|
||||
<TemplateFieldset
|
||||
values={values.templateValues}
|
||||
setValues={(templateAction) =>
|
||||
setValues((values) => {
|
||||
<>
|
||||
<TemplateFieldset
|
||||
values={values.templateValues}
|
||||
setValues={(templateAction) => {
|
||||
const templateValues = applySetStateAction(
|
||||
templateAction,
|
||||
values.templateValues
|
||||
|
@ -74,25 +62,36 @@ export function DockerComposeForm({
|
|||
id: templateValues.templateId,
|
||||
type: templateValues.type,
|
||||
});
|
||||
|
||||
return {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
templateValues,
|
||||
};
|
||||
})
|
||||
}
|
||||
errors={errors?.templateValues}
|
||||
isLoadingValues={isTemplateLoading}
|
||||
/>
|
||||
}));
|
||||
}}
|
||||
errors={errors?.templateValues}
|
||||
/>
|
||||
{values.templateValues.type === 'app' && (
|
||||
<AppTemplateContentField
|
||||
values={values}
|
||||
handleChange={handleChange}
|
||||
errors={errors}
|
||||
setValues={setValues}
|
||||
/>
|
||||
)}
|
||||
{values.templateValues.type === 'custom' && (
|
||||
<CustomTemplateContentField
|
||||
values={values}
|
||||
handleChange={handleChange}
|
||||
errors={errors}
|
||||
setValues={setValues}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(method === editor.value || isTemplate) && !isTemplateLoading && (
|
||||
{method === editor.value && (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
readonly={
|
||||
method === edgeStackTemplate.value && !!customTemplate?.GitConfig
|
||||
}
|
||||
error={errors?.fileContent}
|
||||
/>
|
||||
)}
|
||||
|
@ -154,3 +153,51 @@ export function DockerComposeForm({
|
|||
}));
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateContentFieldProps = {
|
||||
values: DockerFormValues;
|
||||
handleChange: (newValues: Partial<DockerFormValues>) => void;
|
||||
errors?: FormikErrors<DockerFormValues>;
|
||||
setValues: (values: SetStateAction<DockerFormValues>) => void;
|
||||
};
|
||||
|
||||
function AppTemplateContentField({
|
||||
values,
|
||||
handleChange,
|
||||
errors,
|
||||
setValues,
|
||||
}: TemplateContentFieldProps) {
|
||||
const { isInitialLoading } = useRenderAppTemplate(
|
||||
values.templateValues,
|
||||
setValues
|
||||
);
|
||||
return (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
error={errors?.fileContent}
|
||||
isLoading={isInitialLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomTemplateContentField({
|
||||
values,
|
||||
handleChange,
|
||||
errors,
|
||||
setValues,
|
||||
}: TemplateContentFieldProps) {
|
||||
const { customTemplate, isInitialLoading } = useRenderCustomTemplate(
|
||||
values.templateValues,
|
||||
setValues
|
||||
);
|
||||
return (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
error={errors?.fileContent}
|
||||
readonly={!!customTemplate?.GitConfig}
|
||||
isLoading={isInitialLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
export function DockerContentField({
|
||||
|
@ -5,12 +6,18 @@ export function DockerContentField({
|
|||
onChange,
|
||||
readonly,
|
||||
value,
|
||||
isLoading,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
readonly?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <InlineLoader>Loading stack content...</InlineLoader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebEditorForm
|
||||
id="stack-creation-editor"
|
||||
|
|
|
@ -56,6 +56,7 @@ export function InnerForm({
|
|||
onChange={(value) => setFieldValue('name', value)}
|
||||
value={values.name}
|
||||
errors={errors.name}
|
||||
placeholder="e.g. my-stack"
|
||||
/>
|
||||
|
||||
<EdgeGroupsSelector
|
||||
|
@ -128,13 +129,7 @@ export function InnerForm({
|
|||
isEdit={false}
|
||||
values={values.staggerConfig}
|
||||
onChange={(newStaggerValues) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
staggerConfig: {
|
||||
...values.staggerConfig,
|
||||
...newStaggerValues,
|
||||
},
|
||||
}))
|
||||
setFieldValue('staggerConfig', newStaggerValues)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -17,16 +17,19 @@ export function NameField({
|
|||
onChange,
|
||||
value,
|
||||
errors,
|
||||
placeholder,
|
||||
}: {
|
||||
onChange(value: string): void;
|
||||
value: string;
|
||||
errors?: FormikErrors<string>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<FormControl inputId="name-input" label="Name" errors={errors} required>
|
||||
<Input
|
||||
id="name-input"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
required
|
||||
data-cy="edgeStackCreate-nameInput"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { EnvVarsValue } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||
|
||||
export type SelectedTemplateValue =
|
||||
| { templateId: number; type: 'custom' }
|
||||
|
@ -7,5 +8,5 @@ export type SelectedTemplateValue =
|
|||
|
||||
export type Values = {
|
||||
variables: VariablesFieldValue;
|
||||
envVars: Record<string, string>;
|
||||
envVars: EnvVarsValue;
|
||||
} & SelectedTemplateValue;
|
||||
|
|
|
@ -9,14 +9,14 @@ import { Values } from './types';
|
|||
|
||||
export function templateFieldsetValidation({
|
||||
customVariablesDefinitions,
|
||||
envVarDefinitions,
|
||||
appTemplateVariablesDefinitions,
|
||||
}: {
|
||||
customVariablesDefinitions: VariableDefinition[];
|
||||
envVarDefinitions: Array<TemplateEnv>;
|
||||
customVariablesDefinitions: Array<VariableDefinition>;
|
||||
appTemplateVariablesDefinitions: Array<TemplateEnv>;
|
||||
}): SchemaOf<Values> {
|
||||
return object({
|
||||
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
|
||||
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
||||
envVars: envVarsFieldsetValidation(appTemplateVariablesDefinitions)
|
||||
.optional()
|
||||
.when('type', {
|
||||
is: 'app',
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
import { DefaultBodyType, HttpResponse } from 'msw';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { http, server } from '@/setup-tests/server';
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
|
||||
import { mockCodeMirror, renderCreateForm } from './utils.test';
|
||||
|
||||
// keep mockTemplateId and mockTemplateType in module scope
|
||||
let mockTemplateId: number;
|
||||
let mockTemplateType: string;
|
||||
|
||||
// browser address
|
||||
// /edge/stacks/new?templateId=54&templateType=app
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { templateId: mockTemplateId, templateType: mockTemplateType },
|
||||
})),
|
||||
}));
|
||||
|
||||
mockCodeMirror();
|
||||
|
||||
// expected form values
|
||||
const expectedAppTemplatePayload = {
|
||||
deploymentType: 0,
|
||||
edgeGroups: [1],
|
||||
name: 'my-stack',
|
||||
envVars: [{ name: 'LICENSE_KEY', value: 'license-123' }],
|
||||
prePullImage: false,
|
||||
registries: [],
|
||||
retryDeploy: false,
|
||||
staggerConfig: {
|
||||
StaggerOption: 1,
|
||||
StaggerParallelOption: 1,
|
||||
DeviceNumber: 1,
|
||||
DeviceNumberStartFrom: 0,
|
||||
DeviceNumberIncrementBy: 2,
|
||||
Timeout: '',
|
||||
UpdateDelay: '',
|
||||
UpdateFailureAction: 1,
|
||||
},
|
||||
useManifestNamespaces: false,
|
||||
stackFileContent:
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
|
||||
};
|
||||
|
||||
test('The web editor should be visible for app templates', async () => {
|
||||
setMockCreateStackUrlParams(54, 'app');
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
// Wait for the form to be rendered
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// the web editor should be visible
|
||||
expect(getByLabelText('Web editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('The form should submit the correct request body for a given app template', async () => {
|
||||
setMockCreateStackUrlParams(54, 'app');
|
||||
let requestBody: DefaultBodyType;
|
||||
server.use(
|
||||
http.post('/api/edge_stacks/create/string', async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// fill in the name and select the docker edge group
|
||||
const user = userEvent.setup();
|
||||
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
|
||||
await user.type(
|
||||
getByRole('textbox', { name: 'License key *' }),
|
||||
'license-123'
|
||||
);
|
||||
const selectElement = getByLabelText('Edge groups');
|
||||
await selectEvent.select(selectElement, 'docker');
|
||||
|
||||
// submit the form
|
||||
await user.click(getByRole('button', { name: /Deploy the stack/i }));
|
||||
|
||||
// verify the request body
|
||||
await waitFor(() => {
|
||||
expect(requestBody).toEqual(expectedAppTemplatePayload);
|
||||
});
|
||||
});
|
||||
|
||||
function setMockCreateStackUrlParams(templateId: number, templateType: string) {
|
||||
mockTemplateId = templateId;
|
||||
mockTemplateType = templateType;
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import { DefaultBodyType, HttpResponse } from 'msw';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { http, server } from '@/setup-tests/server';
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
|
||||
import { mockCodeMirror, renderCreateForm } from './utils.test';
|
||||
|
||||
// keep mockTemplateId and mockTemplateType in module scope
|
||||
let mockTemplateId: number;
|
||||
let mockTemplateType: string;
|
||||
|
||||
// browser address
|
||||
// /edge/stacks/new?templateId=54&templateType=app
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { templateId: mockTemplateId, templateType: mockTemplateType },
|
||||
})),
|
||||
}));
|
||||
|
||||
mockCodeMirror();
|
||||
|
||||
const expectedCustomTemplatePayload = {
|
||||
deploymentType: 0,
|
||||
edgeGroups: [1],
|
||||
name: 'my-stack',
|
||||
envVars: [],
|
||||
prePullImage: true,
|
||||
registries: [1],
|
||||
retryDeploy: true,
|
||||
staggerConfig: {
|
||||
StaggerOption: 2,
|
||||
StaggerParallelOption: 1,
|
||||
DeviceNumber: 1,
|
||||
DeviceNumberStartFrom: 0,
|
||||
DeviceNumberIncrementBy: 2,
|
||||
Timeout: '3',
|
||||
UpdateDelay: '3',
|
||||
UpdateFailureAction: 3,
|
||||
},
|
||||
useManifestNamespaces: false,
|
||||
repositoryUrl: 'https://github.com/testA113/nginx-public',
|
||||
repositoryUsername: '',
|
||||
repositoryReferenceName: 'refs/heads/main',
|
||||
filePathInRepository: 'docker/voting.yaml',
|
||||
repositoryAuthentication: false,
|
||||
repositoryGitCredentialId: 0,
|
||||
repositoryPassword: '',
|
||||
filesystemPath: '/test',
|
||||
supportRelativePath: true,
|
||||
perDeviceConfigsGroupMatchType: 'file',
|
||||
perDeviceConfigsMatchType: 'file',
|
||||
perDeviceConfigsPath: 'test',
|
||||
tlsSkipVerify: false,
|
||||
autoUpdate: null,
|
||||
};
|
||||
|
||||
test('The web editor should be visible for custom templates', async () => {
|
||||
setMockCreateStackUrlParams(8, 'custom');
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
// Wait for the form to be rendered
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// the web editor should be visible
|
||||
expect(getByLabelText('Web editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('The form should submit the correct request body for a given custom template', async () => {
|
||||
setMockCreateStackUrlParams(8, 'custom');
|
||||
let requestBody: DefaultBodyType;
|
||||
server.use(
|
||||
http.post('/api/edge_stacks/create/repository', async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
const { getByRole, getByLabelText } = renderCreateForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// fill in the name and select the docker edge group
|
||||
const user = userEvent.setup();
|
||||
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
|
||||
const selectElement = getByLabelText('Edge groups');
|
||||
await selectEvent.select(selectElement, 'docker');
|
||||
|
||||
// submit the form
|
||||
await user.click(getByRole('button', { name: /Deploy the stack/i }));
|
||||
|
||||
// verify the request body
|
||||
await waitFor(() => {
|
||||
expect(requestBody).toEqual(expectedCustomTemplatePayload);
|
||||
});
|
||||
});
|
||||
|
||||
function setMockCreateStackUrlParams(templateId: number, templateType: string) {
|
||||
mockTemplateId = templateId;
|
||||
mockTemplateType = templateType;
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
import { HttpResponse } from 'msw';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { http, server } from '@/setup-tests/server';
|
||||
|
||||
import { CreateForm } from '../CreateForm';
|
||||
|
||||
// app templates request
|
||||
// GET /api/templates
|
||||
const appTemplatesResponseBody = {
|
||||
version: '3',
|
||||
templates: [
|
||||
{
|
||||
id: 54,
|
||||
type: 3,
|
||||
title: 'TOSIBOX Lock for Container',
|
||||
description:
|
||||
'Lock for Container brings secure connectivity inside your industrial IoT devices',
|
||||
administrator_only: false,
|
||||
image: '',
|
||||
repository: {
|
||||
url: 'https://github.com/portainer/templates',
|
||||
stackfile: 'stacks/tosibox/docker-compose.yml',
|
||||
},
|
||||
stackFile: '',
|
||||
logo: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/tosibox.png',
|
||||
env: [
|
||||
{
|
||||
name: 'LICENSE_KEY',
|
||||
label: 'License key',
|
||||
},
|
||||
],
|
||||
platform: 'linux',
|
||||
categories: ['edge'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// app template content request
|
||||
// GET /api/templates/54/file
|
||||
const appTemplateContentResponseBody = {
|
||||
FileContent:
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
|
||||
};
|
||||
|
||||
const customTemplatesResponseBody = [
|
||||
{
|
||||
Id: 8,
|
||||
Title: 'git-with-all',
|
||||
Description: 'test',
|
||||
ProjectPath: '/Users/aliharris/portainer-data-ee/custom_templates/8',
|
||||
EntryPoint: '',
|
||||
CreatedByUserId: 1,
|
||||
Note: '',
|
||||
Platform: 1,
|
||||
Logo: '',
|
||||
Type: 2,
|
||||
ResourceControl: {
|
||||
Id: 9,
|
||||
ResourceId: '8',
|
||||
SubResourceIds: [],
|
||||
Type: 8,
|
||||
UserAccesses: [
|
||||
{
|
||||
UserId: 1,
|
||||
AccessLevel: 1,
|
||||
},
|
||||
],
|
||||
TeamAccesses: [],
|
||||
Public: false,
|
||||
AdministratorsOnly: false,
|
||||
System: false,
|
||||
},
|
||||
Variables: [],
|
||||
GitConfig: {
|
||||
URL: 'https://github.com/testA113/nginx-public',
|
||||
ReferenceName: 'refs/heads/main',
|
||||
ConfigFilePath: 'docker/voting.yaml',
|
||||
Authentication: {
|
||||
Username: '',
|
||||
Password: '',
|
||||
GitCredentialID: 0,
|
||||
},
|
||||
ConfigHash: '1db40a888e07da7d9455897aadd349d0bc83bd83',
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
IsComposeFormat: false,
|
||||
EdgeTemplate: true,
|
||||
EdgeSettings: {
|
||||
PrePullImage: true,
|
||||
RetryDeploy: true,
|
||||
PrivateRegistryId: 1,
|
||||
RelativePathSettings: {
|
||||
SupportRelativePath: true,
|
||||
FilesystemPath: '/test',
|
||||
SupportPerDeviceConfigs: true,
|
||||
PerDeviceConfigsMatchType: 'file',
|
||||
PerDeviceConfigsGroupMatchType: 'file',
|
||||
PerDeviceConfigsPath: 'test',
|
||||
},
|
||||
StaggerConfig: {
|
||||
StaggerOption: 2,
|
||||
StaggerParallelOption: 1,
|
||||
DeviceNumber: 1,
|
||||
DeviceNumberStartFrom: 0,
|
||||
DeviceNumberIncrementBy: 2,
|
||||
Timeout: '3',
|
||||
UpdateDelay: '3',
|
||||
UpdateFailureAction: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edgeGroups = [
|
||||
{
|
||||
Id: 1,
|
||||
Name: 'docker',
|
||||
Dynamic: false,
|
||||
TagIds: [],
|
||||
Endpoints: [12],
|
||||
PartialMatch: false,
|
||||
HasEdgeStack: false,
|
||||
HasEdgeJob: false,
|
||||
EndpointTypes: [4],
|
||||
TrustedEndpoints: [12],
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: 'kubernetes',
|
||||
Dynamic: false,
|
||||
TagIds: [],
|
||||
Endpoints: [11],
|
||||
PartialMatch: false,
|
||||
HasEdgeStack: false,
|
||||
HasEdgeJob: false,
|
||||
EndpointTypes: [7],
|
||||
TrustedEndpoints: [11],
|
||||
},
|
||||
];
|
||||
|
||||
const gitCredentials = [
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'test',
|
||||
username: 'portainer-test',
|
||||
creationDate: 1732761658,
|
||||
},
|
||||
];
|
||||
|
||||
const registries = [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer-test',
|
||||
Password: 'test',
|
||||
ManagementConfiguration: {
|
||||
Type: 6,
|
||||
Authentication: true,
|
||||
Username: 'portainer-test',
|
||||
Password: 'test',
|
||||
TLSConfig: {
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
},
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {},
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
AuthorizedUsers: null,
|
||||
AuthorizedTeams: null,
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockCodeMirror();
|
||||
|
||||
test('The form should render', async () => {
|
||||
const { getByRole } = renderCreateForm();
|
||||
|
||||
// Wait for the form to be rendered
|
||||
await waitFor(() => {
|
||||
expect(getByRole('form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
export function mockCodeMirror() {
|
||||
vi.mock('@uiw/react-codemirror', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div />,
|
||||
}));
|
||||
}
|
||||
|
||||
export function renderCreateForm() {
|
||||
// user declaration needs to go at the start for user id related requests (e.g. git credentials)
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
server.use(
|
||||
http.get('/api/templates', () =>
|
||||
HttpResponse.json(appTemplatesResponseBody)
|
||||
)
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/custom_templates', () =>
|
||||
HttpResponse.json(customTemplatesResponseBody)
|
||||
)
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/custom_templates/8', () =>
|
||||
HttpResponse.json(customTemplatesResponseBody[0])
|
||||
)
|
||||
);
|
||||
server.use(
|
||||
http.post('/api/templates/54/file', () =>
|
||||
HttpResponse.json(appTemplateContentResponseBody)
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
|
||||
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
|
||||
server.use(http.get('/api/registries', () => HttpResponse.json(registries)));
|
||||
server.use(
|
||||
http.get('/api/users/1/gitcredentials', () =>
|
||||
HttpResponse.json(gitCredentials)
|
||||
)
|
||||
);
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(CreateForm), user)
|
||||
);
|
||||
return render(<Wrapped />);
|
||||
}
|
|
@ -29,45 +29,36 @@ export function useRenderAppTemplate(
|
|||
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
|
||||
enabled: templateValues.type === 'app',
|
||||
});
|
||||
const [renderedFile, setRenderedFile] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (templateFileQuery.data) {
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
[]
|
||||
);
|
||||
|
||||
if (newFile !== renderedFile) {
|
||||
setRenderedFile(newFile);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
renderedFile,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||
number | undefined
|
||||
>(templateValues.templateId);
|
||||
|
||||
useEffect(() => {
|
||||
if (template?.Id !== currentTemplateId) {
|
||||
if (templateValues.type === 'app' && templateFileQuery.data) {
|
||||
const newTemplateValues = getValuesFromAppTemplate(template);
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
[]
|
||||
);
|
||||
|
||||
setCurrentTemplateId(template?.Id);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...getValuesFromAppTemplate(template),
|
||||
...newTemplateValues,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}, [currentTemplateId, setValues, template]);
|
||||
}, [
|
||||
currentTemplateId,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateFileQuery.isInitialLoading,
|
||||
templateValues.type,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
return {
|
||||
appTemplate: template,
|
||||
|
|
|
@ -29,45 +29,36 @@ export function useRenderCustomTemplate(
|
|||
enabled: templateValues.type === 'custom',
|
||||
}
|
||||
);
|
||||
const [renderedFile, setRenderedFile] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (templateFileQuery.data) {
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
template?.Variables || []
|
||||
);
|
||||
|
||||
if (newFile !== renderedFile) {
|
||||
setRenderedFile(newFile);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
renderedFile,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||
number | undefined
|
||||
>(templateValues.templateId);
|
||||
|
||||
useEffect(() => {
|
||||
if (template?.Id !== currentTemplateId) {
|
||||
if (templateValues.type === 'custom' && templateFileQuery.data) {
|
||||
const newTemplateValues = getValuesFromTemplate(template);
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
template?.Variables || []
|
||||
);
|
||||
|
||||
setCurrentTemplateId(template?.Id);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...getValuesFromTemplate(template),
|
||||
...newTemplateValues,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}, [currentTemplateId, setValues, template]);
|
||||
}, [
|
||||
currentTemplateId,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateFileQuery.isInitialLoading,
|
||||
templateValues.type,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
return {
|
||||
customTemplate: template,
|
||||
|
|
|
@ -1,43 +1,29 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { useParamsState } from '@/react/hooks/useParamState';
|
||||
|
||||
export function useTemplateParams() {
|
||||
const router = useRouter();
|
||||
const [id] = useParamState('templateId', (param) => {
|
||||
if (!param) {
|
||||
return undefined;
|
||||
}
|
||||
const [{ id, type }, setTemplateParams] = useParamsState(
|
||||
['templateId', 'templateType'],
|
||||
(params) => ({
|
||||
id: parseTemplateId(params.templateId),
|
||||
type: parseTemplateType(params.templateType),
|
||||
})
|
||||
);
|
||||
|
||||
const templateId = parseInt(param, 10);
|
||||
if (Number.isNaN(templateId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return templateId;
|
||||
});
|
||||
|
||||
const [type] = useParamState('templateType', (param) => {
|
||||
if (param === 'app' || param === 'custom') {
|
||||
return param;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return [{ id, type }, handleChange] as const;
|
||||
|
||||
function handleChange({
|
||||
id,
|
||||
type,
|
||||
}: {
|
||||
id: number | undefined;
|
||||
type: 'app' | 'custom' | undefined;
|
||||
}) {
|
||||
router.stateService.go(
|
||||
'.',
|
||||
{ templateId: id, templateType: type },
|
||||
{ reload: false }
|
||||
);
|
||||
}
|
||||
return [{ id, type }, setTemplateParams] as const;
|
||||
}
|
||||
|
||||
function parseTemplateId(param?: string) {
|
||||
if (!param) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseInt(param, 10);
|
||||
}
|
||||
|
||||
function parseTemplateType(param?: string): 'app' | 'custom' | undefined {
|
||||
if (param === 'app' || param === 'custom') {
|
||||
return param;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
|
||||
|
@ -31,6 +32,11 @@ export function PrivateRegistryFieldsetWrapper({
|
|||
|
||||
const registriesQuery = useRegistries({ hideDefault: true });
|
||||
|
||||
useEffect(() => {
|
||||
matchRegistry(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [values.file, values.fileContent]);
|
||||
|
||||
if (!registriesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { number, string, object, SchemaOf } from 'yup';
|
||||
import { FormikErrors } from 'formik';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
|
||||
|
@ -36,19 +35,11 @@ const staggerOptions = [
|
|||
] as const;
|
||||
|
||||
export function StaggerFieldset({
|
||||
values: initialValue,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
isEdit = true,
|
||||
}: Props) {
|
||||
const [values, setControlledValues] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
|
||||
|
||||
useEffect(() => {
|
||||
if (!!initialValue && initialValue.StaggerOption !== values.StaggerOption) {
|
||||
setControlledValues(initialValue);
|
||||
}
|
||||
}, [initialValue, values]);
|
||||
|
||||
return (
|
||||
<FormSection title="Update configurations">
|
||||
{!isEdit && (
|
||||
|
@ -208,7 +199,7 @@ export function StaggerFieldset({
|
|||
|
||||
function handleChange(partialValue: Partial<StaggerConfig>) {
|
||||
onChange(partialValue);
|
||||
setControlledValues((values) => ({ ...values, ...partialValue }));
|
||||
// setControlledValues((values) => ({ ...values, ...partialValue }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import { RegistryId } from '@/react/portainer/registries/types/registry';
|
||||
import axios, {
|
||||
json2formData,
|
||||
|
@ -11,7 +11,7 @@ import { buildUrl } from './buildUrl';
|
|||
|
||||
export function useParseRegistries() {
|
||||
return useMutation(parseRegistries, {
|
||||
...withError('Failed parsing registries'),
|
||||
...withGlobalError('Failed parsing registries'),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ export async function parseRegistries({
|
|||
fileContent?: string;
|
||||
}) {
|
||||
if (!file && !fileContent) {
|
||||
throw new Error('File or fileContent must be provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
let currentFile = file;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
/** Only use when you need to use/update a single param at a time. Using this to update multiple params will cause the state to get out of sync. */
|
||||
export function useParamState<T>(
|
||||
param: string,
|
||||
parseParam: (param: string | undefined) => T | undefined = (param) =>
|
||||
|
@ -18,3 +19,36 @@ export function useParamState<T>(
|
|||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
/** Use this when you need to use/update multiple params at once. */
|
||||
export function useParamsState<T extends Record<string, unknown>>(
|
||||
params: string[],
|
||||
parseParams: (params: Record<string, string | undefined>) => T
|
||||
) {
|
||||
const { params: stateParams } = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
|
||||
const state = parseParams(
|
||||
params.reduce(
|
||||
(acc, param) => {
|
||||
acc[param] = stateParams[param];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | undefined>
|
||||
)
|
||||
);
|
||||
|
||||
function setState(newState: Partial<T>) {
|
||||
const newStateParams = Object.entries(newState).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
|
||||
router.stateService.go('.', newStateParams, { reload: false });
|
||||
}
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
|
|
@ -78,6 +78,7 @@ export function RelativePathFieldset({
|
|||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
|
@ -142,6 +143,7 @@ export function RelativePathFieldset({
|
|||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
|
@ -174,6 +176,7 @@ export function RelativePathFieldset({
|
|||
label="Directory"
|
||||
errors={errors?.PerDeviceConfigsPath}
|
||||
inputId="per_device_configs_path_input"
|
||||
required
|
||||
>
|
||||
<PathSelector
|
||||
value={value.PerDeviceConfigsPath || ''}
|
||||
|
|
|
@ -13,7 +13,7 @@ export function relativePathValidation(): SchemaOf<RelativePathModel> {
|
|||
.default(''),
|
||||
SupportPerDeviceConfigs: boolean().default(false),
|
||||
PerDeviceConfigsPath: string()
|
||||
.when(['SupportPerDeviceConfigs'], {
|
||||
.when('SupportPerDeviceConfigs', {
|
||||
is: true,
|
||||
then: string().required('Directory is required'),
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue