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

refactor(registries): migrate list view to react [EE-4704] (#10687)

This commit is contained in:
Chaim Lev-Ari 2024-04-08 17:22:43 +03:00 committed by GitHub
parent 9600eb6fa1
commit f584bf3830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 504 additions and 490 deletions

View file

@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Briefcase } from 'lucide-react';
@ -11,32 +11,39 @@ import { Icon } from '@@/Icon';
import { getFeatureDetails } from './utils';
export interface Props {
featureId?: FeatureId;
featureId: FeatureId;
showIcon?: boolean;
className?: string;
children?: (isLimited: boolean) => ReactNode;
}
export function BEFeatureIndicator({
featureId,
children,
children = () => null,
showIcon = true,
className = '',
}: PropsWithChildren<Props>) {
const { url, limitedToBE } = getFeatureDetails(featureId);
}: Props) {
const { url, limitedToBE = false } = getFeatureDetails(featureId);
if (!limitedToBE) {
return null;
}
return (
<a
className={clsx('be-indicator vertical-center text-xs', className)}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
{showIcon && <Icon icon={Briefcase} className="be-indicator-icon mr-1" />}
<span className="be-indicator-label break-words">Business Feature</span>
</a>
<>
{limitedToBE && (
<a
className={clsx('be-indicator vertical-center text-xs', className)}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{showIcon && (
<Icon icon={Briefcase} className="be-indicator-icon mr-1" />
)}
<span className="be-indicator-label break-words">
Business Feature
</span>
</a>
)}
{children(limitedToBE)}
</>
);
}

View file

@ -55,7 +55,7 @@ export function Switch({
/>
<span className="slider round before:content-['']" />
</label>
{limitedToBE && <BEFeatureIndicator featureId={featureId} />}
{featureId && limitedToBE && <BEFeatureIndicator featureId={featureId} />}
</>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons';

View file

@ -1,5 +1,5 @@
import { Environment } from '@/react/portainer/environments/types';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
enum WebhookType {
Service = 1,

View file

@ -29,7 +29,7 @@ import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGrou
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils';

View file

@ -9,7 +9,7 @@ import {
import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
export interface UpdateEdgeStackGitPayload {
id: EdgeStack['Id'];

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { Select } from '@@/form-components/ReactSelect';
import { FormControl } from '@@/form-components/FormControl';

View file

@ -2,7 +2,7 @@ import axios, {
json2formData,
parseAxiosError,
} from '@/portainer/services/axios';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import { Pair } from '@/react/portainer/settings/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';

View file

@ -1,5 +1,5 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import { Pair } from '@/react/portainer/settings/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';

View file

@ -1,5 +1,5 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import { Pair } from '@/react/portainer/settings/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';

View file

@ -1,7 +1,7 @@
import { useMutation } from 'react-query';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import { Pair } from '@/react/portainer/settings/types';
import {
GitFormModel,

View file

@ -1,7 +1,7 @@
import { useMutation } from 'react-query';
import { withError } from '@/react-tools/react-query';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import axios, {
json2formData,
parseAxiosError,

View file

@ -4,7 +4,7 @@ import {
RelativePathModel,
RepoConfigResponse,
} from '@/react/portainer/gitops/types';
import { RegistryId } from '@/react/portainer/registries/types';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';

View file

@ -1,4 +1,4 @@
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import {

View file

@ -3,7 +3,7 @@ import { MultiValue } from 'react-select';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';

View file

@ -1,7 +1,7 @@
import { FormikErrors } from 'formik';
import { MultiValue } from 'react-select';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';

View file

@ -1,6 +1,6 @@
import { MultiValue } from 'react-select';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Select } from '@@/form-components/ReactSelect';

View file

@ -1,10 +1,11 @@
import { SchemaOf, array, object, number, string } from 'yup';
import { Registry } from '@/react/portainer/registries/types';
import { Registry } from '@/react/portainer/registries/types/registry';
export const registriesValidationSchema: SchemaOf<Registry[]> = array(
object({
Id: number().required('Registry ID is required.'),
Name: string().required('Registry name is required.'),
})
}) as unknown as SchemaOf<Registry>
// the only needed value is actually the id. SchemaOf throw a ts error if we don't cast to SchemaOf<Registry>
);

View file

@ -1,7 +1,10 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TeamId } from '@/react/portainer/users/teams/types';
import { UserId } from '@/portainer/users/types';
import { RegistryId, Registry } from '@/react/portainer/registries/types';
import {
RegistryId,
Registry,
} from '@/react/portainer/registries/types/registry';
import { EnvironmentId } from '../types';

View file

@ -51,7 +51,7 @@ export function Option({
<div className="mt-3 flex flex-col text-center">
<h3>{title}</h3>
<h5>{description}</h5>
{isLimited && (
{featureId && isLimited && (
<BEFeatureIndicator
showIcon={false}
featureId={featureId}

View file

@ -1,3 +0,0 @@
export { DefaultRegistryAction } from './DefaultRegistryAction';
export { DefaultRegistryDomain } from './DefaultRegistryDomain';
export { DefaultRegistryName } from './DefaultRegistryName';

View file

@ -0,0 +1,21 @@
import { PageHeader } from '@@/PageHeader';
import { InformationPanel } from '@@/InformationPanel';
import { RegistriesDatatable } from './RegistriesDatatable';
export function ListView() {
return (
<>
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
<InformationPanel title="Information">
<span className="small text-muted">
View registries via an environment to manage access for user(s) and/or
team(s)
</span>
</InformationPanel>
<RegistriesDatatable />
</>
);
}

View file

@ -0,0 +1,12 @@
import { AddButton as BaseAddButton } from '@@/buttons';
export function AddButton() {
return (
<BaseAddButton
data-cy="registry-addRegistryButton"
to="portainer.registries.new"
>
Add registry
</BaseAddButton>
);
}

View file

@ -0,0 +1,40 @@
import { pluralize } from '@/portainer/helpers/strings';
import { notifySuccess } from '@/portainer/services/notifications';
import { DeleteButton as BaseDeleteButton } from '@@/buttons/DeleteButton';
import { Registry } from '../../types/registry';
import { useDeleteRegistriesMutation } from './useDeleteRegistriesMutation';
export function DeleteButton({ selectedItems }: { selectedItems: Registry[] }) {
const mutation = useDeleteRegistriesMutation();
const confirmMessage = getMessage(selectedItems.length);
return (
<BaseDeleteButton
data-cy="registry-removeRegistryButton"
disabled={selectedItems.length === 0}
confirmMessage={confirmMessage}
onConfirmed={handleDelete}
/>
);
function handleDelete() {
mutation.mutate(
selectedItems.map((item) => item.Id),
{
onSuccess() {
notifySuccess('Success', 'Registries removed');
},
}
);
}
}
function getMessage(selectedCount: number) {
const regAttrMsg = selectedCount > 1 ? 'hese' : 'his';
const registriesMsg = pluralize(selectedCount, 'registry', 'registries');
return `T${regAttrMsg} ${registriesMsg} might be used by applications inside one or more environments. Removing the ${registriesMsg} could lead to a service interruption for the applications using t${regAttrMsg} ${registriesMsg}. Do you want to remove the selected ${registriesMsg}?`;
}

View file

@ -0,0 +1,40 @@
import { Radio } from 'lucide-react';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { useRegistries } from '../../queries/useRegistries';
import { columns } from './columns';
import { DeleteButton } from './DeleteButton';
import { AddButton } from './AddButton';
const tableKey = 'registries';
const store = createPersistedStore(tableKey);
export function RegistriesDatatable() {
const query = useRegistries();
const tableState = useTableState(store, tableKey);
return (
<Datatable
columns={columns}
dataset={query.data || []}
isLoading={query.isLoading}
settingsManager={tableState}
title="Registries"
titleIcon={Radio}
renderTableActions={(selectedItems) => (
<>
<DeleteButton selectedItems={selectedItems} />
<AddButton />
</>
)}
isRowSelectable={(row) => !!row.original.Id}
/>
);
}

View file

@ -38,7 +38,7 @@ export function DefaultRegistryAction() {
Hide for all users
</Button>
<BEFeatureIndicator featureId={FeatureId.HIDE_DOCKER_HUB_ANONYMOUS} />
{isLimited ? null : (
{isLimited && (
<Tooltip
message="This hides the option in any registry dropdown prompts but does not prevent a user from deploying anonymously from Docker Hub directly via YAML.
Note: Docker Hub (anonymous) will continue to show as the ONLY option if there are NO other registries available to the user."

View file

@ -0,0 +1,71 @@
import { CellContext } from '@tanstack/react-table';
import { Search } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
import { DecoratedRegistry } from '../types';
import { RegistryId, RegistryTypes } from '../../../types/registry';
import { columnHelper } from './helper';
import { DefaultRegistryAction } from './DefaultRegistryAction';
export const actions = columnHelper.display({
header: 'Actions',
cell: Cell,
});
const nonBrowsableTypes = [
RegistryTypes.ANONYMOUS,
RegistryTypes.DOCKERHUB,
RegistryTypes.QUAY,
];
function Cell({
row: { original: item },
}: CellContext<DecoratedRegistry, unknown>) {
if (!item.Id) {
return <DefaultRegistryAction />;
}
return <BrowseButton registryId={item.Id} registryType={item.Type} />;
}
export function BrowseButton({
registryId,
registryType,
environmentId,
}: {
registryId: RegistryId;
registryType: RegistryTypes;
environmentId?: EnvironmentId;
}) {
const canBrowse = !nonBrowsableTypes.includes(registryType);
if (!canBrowse) {
return null;
}
return (
<BEFeatureIndicator featureId={FeatureId.REGISTRY_MANAGEMENT}>
{(isLimited) => (
<Button
color="link"
as={Link}
props={{
to: 'portainer.registries.registry.repositories',
params: { id: registryId, endpointId: environmentId },
}}
disabled={isLimited}
icon={Search}
>
Browse
</Button>
)}
</BEFeatureIndicator>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { DecoratedRegistry } from '../types';
export const columnHelper = createColumnHelper<DecoratedRegistry>();

View file

@ -0,0 +1,5 @@
import { actions } from './actions';
import { name } from './name';
import { url } from './url';
export const columns = [name, url, actions];

View file

@ -0,0 +1,52 @@
import { CellContext } from '@tanstack/react-table';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
import { DecoratedRegistry } from '../types';
import { columnHelper } from './helper';
import { DefaultRegistryName } from './DefaultRegistryName';
export const name = columnHelper.accessor('Name', {
header: 'Name',
cell: Cell,
});
function Cell({
row: { original: item },
}: CellContext<DecoratedRegistry, string>) {
return <NameCell item={item} hasLink />;
}
export function NameCell({
item,
hasLink,
}: {
item: DecoratedRegistry;
hasLink?: boolean;
}) {
const isEdgeAdminQuery = useIsEdgeAdmin();
if (!item.Id) {
return <DefaultRegistryName />;
}
return (
<>
{isEdgeAdminQuery.isAdmin && hasLink ? (
<Link to="portainer.registries.registry" params={{ id: item.Id }}>
{item.Name}
</Link>
) : (
item.Name
)}
{item.Authentication && (
<span className="ml-2 label label-info image-tag">
authentication-enabled
</span>
)}
</>
);
}

View file

@ -0,0 +1,8 @@
import { DefaultRegistryDomain } from './DefaultRegistryDomain';
import { columnHelper } from './helper';
export const url = columnHelper.accessor('URL', {
header: 'URL',
cell: ({ getValue, row: { original: item } }) =>
item.Id ? getValue() : <DefaultRegistryDomain />,
});

View file

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

View file

@ -0,0 +1,3 @@
import { Registry } from '../../types/registry';
export interface DecoratedRegistry extends Registry {}

View file

@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from 'react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { buildUrl } from '../../queries/build-url';
import { queryKeys } from '../../queries/query-keys';
import { Registry } from '../../types/registry';
export function useDeleteRegistriesMutation() {
const queryClient = useQueryClient();
return useMutation(
(RegistryIds: Array<Registry['Id']>) =>
promiseSequence(
RegistryIds.map((RegistryId) => () => deleteRegistry(RegistryId))
),
mutationOptions(
withError('Unable to delete registries'),
withInvalidate(queryClient, [queryKeys.base()])
)
);
}
async function deleteRegistry(id: Registry['Id']) {
try {
await axios.delete(buildUrl(id));
} catch (e) {
throw parseAxiosError(e, 'Unable to delete registries');
}
}

View file

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

View file

@ -0,0 +1,39 @@
import { Radio } from 'lucide-react';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { url } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/url';
import { AddButton } from '@/react/portainer/registries/ListView/RegistriesDatatable/AddButton';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { name } from './columns/name';
import { actions } from './columns/actions';
const columns = [name, url, actions];
const tableKey = 'registries';
const store = createPersistedStore(tableKey);
export function EnvironmentRegistriesDatatable() {
const environmentId = useEnvironmentId();
const query = useEnvironmentRegistries(environmentId);
const tableState = useTableState(store, tableKey);
return (
<Datatable
columns={columns}
dataset={query.data || []}
isLoading={query.isLoading}
settingsManager={tableState}
title="Registries"
titleIcon={Radio}
renderTableActions={() => <AddButton />}
disableSelect
/>
);
}

View file

@ -0,0 +1,56 @@
import { CellContext } from '@tanstack/react-table';
import { Users } from 'lucide-react';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { DecoratedRegistry } from '@/react/portainer/registries/ListView/RegistriesDatatable/types';
import { RegistryTypes } from '@/react/portainer/registries/types/registry';
import { columnHelper } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/helper';
import { BrowseButton } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/actions';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
export const actions = columnHelper.display({
header: 'Actions',
cell: Cell,
});
function Cell({
row: { original: item },
}: CellContext<DecoratedRegistry, unknown>) {
const environmentId = useEnvironmentId();
const hasUpdateAccessAuthorizations = useAuthorizations(
['PortainerRegistryUpdateAccess'],
environmentId,
true
);
const canManageAccess =
item.Type !== RegistryTypes.ANONYMOUS && hasUpdateAccessAuthorizations;
if (!item.Id) {
return null;
}
return (
<>
{canManageAccess && (
<Authorized authorizations="PortainerRegistryUpdateAccess">
<Button
color="link"
icon={Users}
as={Link}
props={{ to: '.access', params: { id: item.Id } }}
>
Manage access
</Button>
</Authorized>
)}
<BrowseButton
registryId={item.Id}
registryType={item.Type}
environmentId={environmentId}
/>
</>
);
}

View file

@ -0,0 +1,16 @@
import { CellContext } from '@tanstack/react-table';
import { DecoratedRegistry } from '@/react/portainer/registries/ListView/RegistriesDatatable/types';
import { columnHelper } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/helper';
import { NameCell } from '@/react/portainer/registries/ListView/RegistriesDatatable/columns/name';
export const name = columnHelper.accessor('Name', {
header: 'Name',
cell: Cell,
});
function Cell({
row: { original: item },
}: CellContext<DecoratedRegistry, string>) {
return <NameCell item={item} />;
}

View file

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

View file

@ -0,0 +1,17 @@
import { PageHeader } from '@@/PageHeader';
import { EnvironmentRegistriesDatatable } from './EnvironmentRegistriesDatatable';
export function ListView() {
return (
<>
<PageHeader
title="Environment registries"
breadcrumbs="Registry management"
reload
/>
<EnvironmentRegistriesDatatable />
</>
);
}

View file

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

View file

@ -1,6 +1,9 @@
import { EnvironmentId } from '../../environments/types';
import { RegistryId } from '../types/registry';
export const queryKeys = {
base: () => ['registries'] as const,
list: (environmentId?: EnvironmentId) =>
[...queryKeys.base(), { environmentId }] as const,
item: (registryId: RegistryId) => [...queryKeys.base(), registryId] as const,
};

View file

@ -1,5 +0,0 @@
export type RegistryId = number;
export interface Registry {
Id: RegistryId;
Name: string;
}

View file

@ -5,7 +5,7 @@ import { ResourceControlResponse } from '../../access-control/types';
import { RelativePathModel, RepoConfigResponse } from '../../gitops/types';
import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { Platform } from '../types';
import { RegistryId } from '../../registries/types';
import { RegistryId } from '../../registries/types/registry';
import { getDefaultRelativePathModel } from '../../gitops/RelativePathFieldset/types';
import { isBE } from '../../feature-flags/feature-flags.service';