1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

refactor(edge/groups): migrate view to react [EE-2219] (#11758)
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-06-02 15:43:37 +03:00 committed by GitHub
parent b7cde35c3d
commit 9c70a43ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 579 additions and 386 deletions

View file

@ -0,0 +1,10 @@
import { useRouter } from '@uirouter/react';
import { useEffect } from 'react';
export function Redirect({ to, params = {} }: { to: string; params?: object }) {
const router = useRouter();
useEffect(() => {
router.stateService.go(to, params);
}, [params, router.stateService, to]);
return null;
}

View file

@ -6,6 +6,7 @@ import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
import { Creatable, Select } from '@@/form-components/ReactSelect';
import { FormControl } from '@@/form-components/FormControl';
import { Link } from '@@/Link';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { TagButton } from '../TagButton';
@ -13,6 +14,7 @@ interface Props {
value: TagId[];
allowCreate?: boolean;
onChange(value: TagId[]): void;
errors?: ArrayError<TagId[]>;
}
interface Option {
@ -20,7 +22,12 @@ interface Option {
label: string;
}
export function TagSelector({ value, allowCreate = false, onChange }: Props) {
export function TagSelector({
value,
allowCreate = false,
onChange,
errors,
}: Props) {
// change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989)
const tagsQuery = useTags({
select: (tags) => tags?.map((opt) => ({ label: opt.Name, value: opt.ID })),
@ -62,19 +69,29 @@ export function TagSelector({ value, allowCreate = false, onChange }: Props) {
<>
{value.length > 0 && (
<FormControl label="Selected tags">
{selectedTags.map((tag) => (
<TagButton
key={tag.value}
title="Remove tag"
value={tag.value}
label={tag.label}
onRemove={() => handleRemove(tag.value)}
/>
))}
<div data-cy="selected-tags">
{selectedTags.map((tag) => (
<TagButton
key={tag.value}
title="Remove tag"
value={tag.value}
label={tag.label}
onRemove={() => handleRemove(tag.value)}
/>
))}
</div>
</FormControl>
)}
<FormControl label="Tags" inputId="tags-selector">
<FormControl
label="Tags"
inputId="tags-selector"
errors={
typeof errors === 'string'
? errors
: errors?.map((e) => e?.toString())
}
>
<SelectComponent
inputId="tags-selector"
value={[] as { label: string; value: number }[]}

View file

@ -11,6 +11,7 @@ interface Props extends AutomationTestingProps {
loadingText: string;
isLoading: boolean;
isValid: boolean;
errors?: unknown;
}
export function FormActions({
@ -19,6 +20,7 @@ export function FormActions({
isLoading,
children,
isValid,
errors,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
return (
@ -35,6 +37,13 @@ export function FormActions({
>
{submitLabel}
</LoadingButton>
{!isValid && (
<div className="hidden" data-cy="errors">
{JSON.stringify(errors)}
</div>
)}
{children}
</div>
</div>

View file

@ -1,16 +1,21 @@
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
import { FormError } from '@@/form-components/FormError';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
export function AssociatedEdgeEnvironmentsSelector({
onChange,
value,
error,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
error?: ArrayError<Array<EnvironmentId>>;
}) {
return (
<>
@ -20,6 +25,14 @@ export function AssociatedEdgeEnvironmentsSelector({
environment entry to move it from one table to the other.
</div>
{error && (
<div className="col-sm-12">
<FormError>
{typeof error === 'string' ? error : error.join(', ')}
</FormError>
</div>
)}
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">

View file

@ -2,15 +2,15 @@ import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useTags } from '@/portainer/tags/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Environment } from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
type DecoratedEnvironment = Environment & {
Tags: string[];
@ -41,12 +41,12 @@ const columns = [
export function EdgeGroupAssociationTable({
title,
query,
onClickRow,
onClickRow = () => {},
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
onClickRow: (env: Environment) => void;
onClickRow?: (env: Environment) => void;
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(0);

View file

@ -1,11 +1,11 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { useCreateGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation';
import { useCreateEdgeGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { CreatableSelector } from './CreatableSelector';
export function EdgeGroupsSelector() {
const createMutation = useCreateGroupMutation();
const createMutation = useCreateEdgeGroupMutation();
const edgeGroupsQuery = useEdgeGroups({
select: (edgeGroups) =>

View file

@ -0,0 +1,55 @@
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { useCreateEdgeGroupMutation } from '../queries/useCreateEdgeGroupMutation';
import { EdgeGroupForm } from '../components/EdgeGroupForm/EdgeGroupForm';
export function CreateView() {
const mutation = useCreateEdgeGroupMutation();
const router = useRouter();
return (
<>
<PageHeader
title="Create edge group"
breadcrumbs={[
{ label: 'Edge groups', link: 'edge.groups' },
'Add edge group',
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<EdgeGroupForm
onSubmit={({ environmentIds, ...values }) => {
mutation.mutate(
{
endpoints: environmentIds,
...values,
},
{
onSuccess: () => {
notifySuccess(
'Success',
'Edge group successfully created'
);
router.stateService.go('^');
},
}
);
}}
isLoading={mutation.isLoading}
/>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,72 @@
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { Redirect } from '@@/Redirect';
import { useUpdateEdgeGroupMutation } from '../queries/useUpdateEdgeGroupMutation';
import { EdgeGroupForm } from '../components/EdgeGroupForm/EdgeGroupForm';
import { useEdgeGroup } from '../queries/useEdgeGroup';
export function ItemView() {
const {
params: { groupId: id },
} = useCurrentStateAndParams();
const groupQuery = useEdgeGroup(id);
const mutation = useUpdateEdgeGroupMutation();
const router = useRouter();
if (groupQuery.isError) {
return <Redirect to="edge.groups" />;
}
if (!groupQuery.data) {
return null;
}
const group = groupQuery.data;
return (
<>
<PageHeader
title="Edit edge group"
breadcrumbs={[
{ label: 'Edge groups', link: 'edge.groups' },
group.Name,
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<EdgeGroupForm
group={group}
onSubmit={({ environmentIds, ...values }) => {
mutation.mutate(
{
id,
endpoints: environmentIds,
...values,
},
{
onSuccess: () => {
notifySuccess(
'Success',
'Edge group successfully updated'
);
router.stateService.go('^');
},
}
);
}}
isLoading={mutation.isLoading}
/>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,45 @@
import { useFormikContext } from 'formik';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { EdgeTypes } from '@/react/portainer/environments/types';
import { BoxSelector } from '@@/BoxSelector';
import { TagSelector } from '@@/TagSelector';
import { FormSection } from '@@/form-components/FormSection';
import { tagOptions } from './tag-options';
import { FormValues } from './types';
export function DynamicGroupFieldset() {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
return (
<>
<FormSection title="Tags">
<BoxSelector
slim
value={values.partialMatch}
onChange={(partialMatch) =>
setFieldValue('partialMatch', partialMatch)
}
options={tagOptions}
radioName="partialMatch"
/>
<TagSelector
value={values.tagIds}
onChange={(tagIds) => setFieldValue('tagIds', tagIds)}
errors={errors.tagIds}
/>
</FormSection>
<EdgeGroupAssociationTable
data-cy="edgeGroupCreate-associatedEnvironmentsTable"
title="Associated environments by tags"
query={{
types: EdgeTypes,
tagIds: values.tagIds,
tagsPartialMatch: values.partialMatch,
}}
/>
</>
);
}

View file

@ -0,0 +1,90 @@
import { Form, Formik, useFormikContext } from 'formik';
import { FormSection } from '@@/form-components/FormSection';
import { BoxSelector } from '@@/BoxSelector';
import { FormActions } from '@@/form-components/FormActions';
import { EdgeGroup } from '../../types';
import { groupTypeOptions } from './group-type-options';
import { FormValues } from './types';
import { useValidation } from './useValidation';
import { DynamicGroupFieldset } from './DynamicGroupFieldset';
import { StaticGroupFieldset } from './StaticGroupFieldset';
import { NameField } from './NameField';
export function EdgeGroupForm({
onSubmit,
isLoading,
group,
}: {
onSubmit: (values: FormValues) => void;
isLoading: boolean;
group?: EdgeGroup;
}) {
const validation = useValidation({ id: group?.Id });
return (
<Formik
validationSchema={validation}
validateOnMount
initialValues={
group
? {
dynamic: group.Dynamic,
environmentIds: group.Endpoints,
name: group.Name,
partialMatch: group.PartialMatch,
tagIds: group.TagIds,
}
: {
name: '',
dynamic: false,
environmentIds: [],
partialMatch: false,
tagIds: [],
}
}
onSubmit={onSubmit}
>
<InnerForm isLoading={isLoading} isCreate={!group} />
</Formik>
);
}
function InnerForm({
isLoading,
isCreate,
}: {
isLoading: boolean;
isCreate: boolean;
}) {
const { values, setFieldValue, isValid, errors } =
useFormikContext<FormValues>();
return (
<Form className="form-horizontal">
<NameField errors={errors.name} />
<FormSection title="Group type">
<BoxSelector
slim
value={values.dynamic}
onChange={(dynamic) => setFieldValue('dynamic', dynamic)}
options={groupTypeOptions}
radioName="groupTypeDynamic"
/>
</FormSection>
{values.dynamic ? <DynamicGroupFieldset /> : <StaticGroupFieldset />}
<FormActions
submitLabel={isCreate ? 'Add edge group' : 'Save edge group'}
isLoading={isLoading}
isValid={isValid}
data-cy="edgeGroupCreate-addGroupButton"
loadingText="In progress..."
errors={errors}
/>
</Form>
);
}

View file

@ -0,0 +1,42 @@
import { Field, FormikErrors } from 'formik';
import { string } from 'yup';
import { useMemo } from 'react';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { useEdgeGroups } from '../../queries/useEdgeGroups';
import { EdgeGroup } from '../../types';
export function NameField({ errors }: { errors?: FormikErrors<string> }) {
return (
<FormControl label="Name" required errors={errors} inputId="group_name">
<Field
as={Input}
name="name"
placeholder="e.g. mygroup"
data-cy="edgeGroupCreate-groupNameInput"
id="group_name"
/>
</FormControl>
);
}
export function useNameValidation(id?: EdgeGroup['Id']) {
const edgeGroupsQuery = useEdgeGroups();
return useMemo(
() =>
string()
.required('Name is required')
.test({
name: 'is-unique',
test: (value) =>
!edgeGroupsQuery.data?.find(
(group) => group.Name === value && group.Id !== id
),
message: 'Name must be unique',
}),
[edgeGroupsQuery.data, id]
);
}

View file

@ -0,0 +1,40 @@
import { useFormikContext } from 'formik';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { FormSection } from '@@/form-components/FormSection';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { FormValues } from './types';
export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
return (
<FormSection title="Associated environments">
<div className="form-group">
<AssociatedEdgeEnvironmentsSelector
value={values.environmentIds}
error={errors.environmentIds}
onChange={async (environmentIds, meta) => {
if (meta.type === 'remove' && isEdit) {
const confirmed = await confirmDestructive({
title: 'Confirm action',
message:
'Removing the environment from this group will remove its corresponding edge stacks',
confirmButton: buildConfirmButton('Confirm'),
});
if (!confirmed) {
return;
}
}
setFieldValue('environmentIds', environmentIds);
}}
/>
</div>
</FormSection>
);
}

View file

@ -0,0 +1,10 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { TagId } from '@/portainer/tags/types';
export interface FormValues {
name: string;
dynamic: boolean;
environmentIds: EnvironmentId[];
partialMatch: boolean;
tagIds: TagId[];
}

View file

@ -0,0 +1,27 @@
import { SchemaOf, array, boolean, number, object } from 'yup';
import { useMemo } from 'react';
import { EdgeGroup } from '../../types';
import { FormValues } from './types';
import { useNameValidation } from './NameField';
export function useValidation({
id,
}: { id?: EdgeGroup['Id'] } = {}): SchemaOf<FormValues> {
const nameValidation = useNameValidation(id);
return useMemo(
() =>
object({
name: nameValidation,
dynamic: boolean().default(false),
environmentIds: array(number().required()),
partialMatch: boolean().default(false),
tagIds: array(number().required()).when('dynamic', {
is: true,
then: (schema) => schema.min(1, 'Tags are required'),
}),
}),
[nameValidation]
);
}

View file

@ -1,3 +1,4 @@
export const queryKeys = {
base: () => ['edge', 'groups'] as const,
item: (id: number) => [...queryKeys.base(), id] as const,
};

View file

@ -34,7 +34,7 @@ export async function createEdgeGroup(requestPayload: CreateGroupPayload) {
}
}
export function useCreateGroupMutation() {
export function useCreateEdgeGroupMutation() {
const queryClient = useQueryClient();
return useMutation(

View file

@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
EnvironmentId,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { EdgeGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
export interface EdgeGroupListItemResponse extends EdgeGroup {
EndpointTypes: Array<EnvironmentType>;
HasEdgeStack?: boolean;
HasEdgeJob?: boolean;
HasEdgeConfig?: boolean;
TrustedEndpoints: Array<EnvironmentId>;
}
async function getEdgeGroup(id: EdgeGroup['Id']) {
try {
const { data } = await axios.get<EdgeGroup>(buildUrl({ id }));
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
}
}
export function useEdgeGroup<T = EdgeGroup>(
id?: EdgeGroup['Id'],
{
select,
}: {
select?: (groups: EdgeGroup) => T;
} = {}
) {
return useQuery({
queryKey: queryKeys.item(id!),
queryFn: () => getEdgeGroup(id!),
select,
enabled: !!id,
...withError('Failed fetching edge group'),
});
}

View file

@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeGroup } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
interface UpdateGroupPayload {
id: EdgeGroup['Id'];
name: string;
dynamic: boolean;
tagIds?: TagId[];
endpoints?: EnvironmentId[];
partialMatch?: boolean;
}
export async function updateEdgeGroup({
id,
...requestPayload
}: UpdateGroupPayload) {
try {
const { data: group } = await axios.put<EdgeGroup>(
buildUrl({ id }),
requestPayload
);
return group;
} catch (e) {
throw parseAxiosError(e as Error, 'Failed to update Edge group');
}
}
export function useUpdateEdgeGroupMutation() {
const queryClient = useQueryClient();
return useMutation(
updateEdgeGroup,
mutationOptions(
withError('Failed to update Edge group'),
withInvalidate(queryClient, [queryKeys.base()])
)
);
}