1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-08 23:35:31 +02:00

refactor(custom-templates): migrate list view to react [EE-2256] (#11611)
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled

This commit is contained in:
Chaim Lev-Ari 2024-05-30 12:04:28 +03:00 committed by GitHub
parent 5c6c66f010
commit 94c91035a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 200 additions and 617 deletions

View file

@ -15,20 +15,20 @@ import { CustomTemplatesListItem } from './CustomTemplatesListItem';
export function CustomTemplatesList({
templates,
onSelect,
onDelete,
selectedId,
templateLinkParams,
storageKey,
}: {
templates?: CustomTemplate[];
onSelect?: (template: CustomTemplate['Id']) => void;
onDelete: (template: CustomTemplate['Id']) => void;
onDelete: (templateId: CustomTemplate['Id']) => void;
selectedId?: CustomTemplate['Id'];
templateLinkParams?: (template: CustomTemplate) => {
to: string;
params: object;
};
templateLinkParams?: (template: CustomTemplate) =>
| {
to: string;
params: object;
}
| undefined;
storageKey: string;
}) {
const [page, setPage] = useState(0);
@ -68,7 +68,6 @@ export function CustomTemplatesList({
<CustomTemplatesListItem
key={template.Id}
template={template}
onSelect={onSelect}
isSelected={template.Id === selectedId}
onDelete={onDelete}
linkParams={templateLinkParams?.(template)}

View file

@ -0,0 +1,58 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { useParamState } from '@/react/hooks/useParamState';
import { PageHeader } from '@@/PageHeader';
import { confirmDelete } from '@@/modals/confirm';
import { useCustomTemplates } from '../queries/useCustomTemplates';
import { useDeleteTemplateMutation } from '../queries/useDeleteTemplateMutation';
import { CustomTemplate } from '../types';
import { StackFromCustomTemplateFormWidget } from './StackFromCustomTemplateFormWidget';
import { CustomTemplatesList } from './CustomTemplatesList';
import { useViewParams } from './useViewParams';
export function ListView() {
const { params, getTemplateLinkParams, storageKey, viewType } =
useViewParams();
const templatesQuery = useCustomTemplates({
params,
});
const deleteMutation = useDeleteTemplateMutation();
const [selectedTemplateId] = useParamState<number>('template', (param) =>
param ? parseInt(param, 10) : 0
);
return (
<>
<PageHeader title="Custom Templates" breadcrumbs="Custom Templates" />
{viewType === 'docker' && !!selectedTemplateId && (
<StackFromCustomTemplateFormWidget templateId={selectedTemplateId} />
)}
<CustomTemplatesList
templates={templatesQuery.data}
onDelete={handleDelete}
templateLinkParams={getTemplateLinkParams}
storageKey={storageKey}
selectedId={selectedTemplateId}
/>
</>
);
async function handleDelete(templateId: CustomTemplate['Id']) {
if (
!(await confirmDelete('Are you sure you want to delete this template?'))
) {
return;
}
deleteMutation.mutate(templateId, {
onSuccess: () => {
notifySuccess('Success', 'Template deleted');
},
});
}
}

View file

@ -0,0 +1,246 @@
import { useRouter } from '@uirouter/react';
import { Formik, Form } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import {
CreateStackPayload,
useCreateStack,
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { NameField } from '@/react/common/stacks/CreateView/NameField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
isTemplateVariablesEnabled,
renderTemplate,
} from '@/react/portainer/custom-templates/components/utils';
import {
CustomTemplatesVariablesField,
getVariablesFieldDefaultValues,
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { StackType } from '@/react/common/stacks/types';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { AdvancedSettings } from '@/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings';
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
import { Button } from '@@/buttons';
import { FormActions } from '@@/form-components/FormActions';
import { FormSection } from '@@/form-components/FormSection';
import { WebEditorForm } from '@@/WebEditorForm';
import { Link } from '@@/Link';
import { FormValues } from './types';
import { useValidation } from './useValidation';
export function DeployForm({
template,
templateFile,
isDeployable,
}: {
template: CustomTemplate;
templateFile: string;
isDeployable: boolean;
}) {
const router = useRouter();
const { user } = useCurrentUser();
const isEdgeAdminQuery = useIsEdgeAdmin();
const environmentId = useEnvironmentId();
const swarmIdQuery = useSwarmId(environmentId);
const mutation = useCreateStack();
const validation = useValidation({
isDeployable,
variableDefs: template.Variables,
isAdmin: isEdgeAdminQuery.isAdmin,
environmentId,
});
if (isEdgeAdminQuery.isLoading) {
return null;
}
const isGit = !!template.GitConfig;
const initialValues: FormValues = {
name: template.Title || '',
variables: getVariablesFieldDefaultValues(template.Variables),
accessControl: parseAccessControlFormData(
isEdgeAdminQuery.isAdmin,
user.Id
),
fileContent: templateFile,
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
errors={errors.name}
/>
</FormSection>
{isTemplateVariablesEnabled && (
<CustomTemplatesVariablesField
definitions={template.Variables}
onChange={(v) => {
setFieldValue('variables', v);
const newFile = renderTemplate(
templateFile,
v,
template.Variables
);
setFieldValue('fileContent', newFile);
}}
value={values.variables}
errors={errors.variables}
/>
)}
<AdvancedSettings
label={(isOpen) => advancedSettingsLabel(isOpen, isGit)}
>
<WebEditorForm
id="custom-template-creation-editor"
value={values.fileContent}
onChange={(value) => {
if (isGit) {
return;
}
setFieldValue('fileContent', value);
}}
yaml
error={errors.fileContent}
placeholder="Define or paste the content of your docker compose file here"
readonly={isGit}
data-cy="custom-template-creation-editor"
>
<p>
You can get more information about Compose file format in the{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</p>
</WebEditorForm>
</AdvancedSettings>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<FormActions
isLoading={mutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the stack"
data-cy="deploy-stack-button"
>
<Button
type="reset"
as={Link}
props={{
to: '.',
'data-cy': 'cancel-stack-creation',
params: { template: null },
}}
color="default"
data-cy="cancel-stack-creation"
>
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
const payload = getPayload(values);
return mutation.mutate(payload, {
onSuccess() {
notifySuccess('Success', 'Stack created');
router.stateService.go('docker.stacks');
},
});
}
function getPayload(values: FormValues): CreateStackPayload {
const type =
template.Type === StackType.DockerCompose ? 'standalone' : 'swarm';
const isGit = !!template.GitConfig;
if (isGit) {
return type === 'standalone'
? {
type,
method: 'git',
payload: {
name: values.name,
environmentId,
git: toGitFormModel(template.GitConfig),
accessControl: values.accessControl,
},
}
: {
type,
method: 'git',
payload: {
name: values.name,
environmentId,
swarmId: swarmIdQuery.data || '',
git: toGitFormModel(template.GitConfig),
accessControl: values.accessControl,
},
};
}
return type === 'standalone'
? {
type,
method: 'string',
payload: {
name: values.name,
environmentId,
fileContent: values.fileContent,
accessControl: values.accessControl,
},
}
: {
type,
method: 'string',
payload: {
name: values.name,
environmentId,
swarmId: swarmIdQuery.data || '',
fileContent: values.fileContent,
accessControl: values.accessControl,
},
};
}
}
function advancedSettingsLabel(isOpen: boolean, isGit: boolean) {
if (isGit) {
return isOpen ? 'Hide stack' : 'View stack';
}
return isOpen ? 'Hide custom stack' : 'Customize stack';
}

View file

@ -0,0 +1,60 @@
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { TextTip } from '@@/Tip/TextTip';
import { useIsDeployable } from './useIsDeployable';
import { DeployForm } from './DeployForm';
import { TemplateLoadError } from './TemplateLoadError';
export function StackFromCustomTemplateFormWidget({
templateId,
}: {
templateId: CustomTemplate['Id'];
}) {
const templateQuery = useCustomTemplate(templateId);
const isDeployable = useIsDeployable(templateQuery.data?.Type);
const fileQuery = useCustomTemplateFile(templateId);
if (fileQuery.isLoading || !templateQuery.data) {
return null;
}
const template = templateQuery.data;
return (
<DeployWidget
logo={template.Logo}
note={template.Note}
title={template.Title}
>
{fileQuery.isError && (
<TemplateLoadError
creatorId={template.CreatedByUserId}
templateId={template.Id}
/>
)}
{!isDeployable && (
<div className="form-group">
<div className="col-sm-12">
<TextTip>
This template type cannot be deployed on this environment.
</TextTip>
</div>
</div>
)}
{fileQuery.isSuccess && isDeployable && (
<DeployForm
template={template}
templateFile={fileQuery.data}
isDeployable={isDeployable}
/>
)}
</DeployWidget>
);
}

View file

@ -0,0 +1,46 @@
import { UserId } from '@/portainer/users/types';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { Link } from '@@/Link';
import { FormError } from '@@/form-components/FormError';
export function TemplateLoadError({
templateId,
creatorId,
}: {
templateId: CustomTemplate['Id'];
creatorId: UserId;
}) {
const { user } = useCurrentUser();
const isEdgeAdminQuery = useIsEdgeAdmin();
if (isEdgeAdminQuery.isLoading) {
return null;
}
const isAdminOrWriter = isEdgeAdminQuery.isAdmin || user.Id === creatorId;
return (
<FormError>
{isAdminOrWriter ? (
<>
Custom template could not be loaded, please{' '}
<Link
to=".edit"
params={{ id: templateId }}
data-cy="edit-custom-template-link"
>
click here
</Link>{' '}
for configuration
</>
) : (
<>
Custom template could not be loaded, please contact your
administrator.
</>
)}
</FormError>
);
}

View file

@ -0,0 +1 @@
export { StackFromCustomTemplateFormWidget } from './StackFromCustomTemplateFormWidget';

View file

@ -0,0 +1,9 @@
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
export interface FormValues {
name: string;
variables: VariablesFieldValue;
accessControl: AccessControlFormData;
fileContent: string;
}

View file

@ -0,0 +1,19 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { StackType } from '@/react/common/stacks/types';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
export function useIsDeployable(type: StackType | undefined) {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
switch (type) {
case StackType.DockerCompose:
return !isSwarm;
case StackType.DockerSwarm:
return isSwarm;
case StackType.Kubernetes:
default:
return false;
}
}

View file

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { object, string } from 'yup';
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { EnvironmentId } from '@/react/portainer/environments/types';
export function useValidation({
environmentId,
isAdmin,
variableDefs,
isDeployable,
}: {
variableDefs: Array<VariableDefinition>;
isAdmin: boolean;
environmentId: EnvironmentId;
isDeployable: boolean;
}) {
const name = useNameValidation(environmentId);
return useMemo(
() =>
object({
name: name.test({
name: 'is-deployable',
message: 'This template cannot be deployed on this environment',
test: () => isDeployable,
}),
accessControl: accessControlFormValidation(isAdmin),
fileContent: string().required('Required'),
variables: variablesFieldValidation(variableDefs),
}),
[isAdmin, isDeployable, name, variableDefs]
);
}

View file

@ -0,0 +1,73 @@
import { StackType } from '@/react/common/stacks/types';
import { useAuthorizations } from '@/react/hooks/useUser';
import { CustomTemplatesListParams } from '../queries/useCustomTemplates';
import { CustomTemplate } from '../types';
import { TemplateViewType, useViewType } from '../useViewType';
export function useViewParams(): {
viewType: TemplateViewType;
params: CustomTemplatesListParams;
getTemplateLinkParams?: (template: CustomTemplate) => {
to: string;
params: object;
};
storageKey: string;
} {
const viewType = useViewType();
const isAllowedDeploymentKubeQuery = useAuthorizations(
'K8sApplicationsAdvancedDeploymentRW'
);
const isAllowedDeploymentDockerQuery = useAuthorizations([
'DockerContainerCreate',
'PortainerStackCreate',
]);
switch (viewType) {
case 'kube':
return {
viewType,
params: { edge: false, type: [StackType.Kubernetes] },
getTemplateLinkParams: isAllowedDeploymentKubeQuery.authorized
? (template: CustomTemplate) => ({
to: 'kubernetes.deploy',
params: { templateId: template.Id, templateType: 'custom' },
})
: undefined,
storageKey: 'kube-custom-templates',
};
case 'edge':
return {
viewType,
params: { edge: true },
getTemplateLinkParams: (template: CustomTemplate) => ({
to: 'edge.stacks.new',
params: { templateId: template.Id, templateType: 'custom' },
}),
storageKey: 'edge-custom-templates',
};
case 'docker':
return {
viewType,
params: {
edge: false,
type: [StackType.DockerCompose, StackType.DockerSwarm],
},
getTemplateLinkParams: isAllowedDeploymentDockerQuery.authorized
? (template: CustomTemplate) => ({
to: '.',
params: { template: template.Id },
})
: undefined,
storageKey: 'docker-custom-templates',
};
default:
return {
viewType,
params: {},
getTemplateLinkParams: undefined,
storageKey: 'custom-templates',
};
}
}

View file

@ -20,6 +20,8 @@ type Params = {
edge?: boolean;
};
export { type Params as CustomTemplatesListParams };
export function useCustomTemplates<T = Array<CustomTemplate>>({
select,
params,
@ -38,6 +40,9 @@ async function getCustomTemplates({ type, edge }: Params = {}) {
type,
edge,
},
paramsSerializer: {
indexes: null,
},
});
return data;
} catch (e) {

View file

@ -4,15 +4,19 @@ export type TemplateViewType = 'kube' | 'docker' | 'edge';
export function useViewType(): TemplateViewType {
const {
state: { name },
state: { name = '' },
} = useCurrentStateAndParams();
if (name?.includes('kubernetes')) {
if (name.includes('kubernetes')) {
return 'kube';
}
if (name?.includes('docker')) {
if (name.includes('docker')) {
return 'docker';
}
return 'edge';
if (name.includes('edge')) {
return 'edge';
}
throw new Error(`Unknown view type: ${name}`);
}