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

refactor(groups): migrate groups selectors to react [EE-3842] (#8936)

This commit is contained in:
Chaim Lev-Ari 2023-06-22 21:11:10 +07:00 committed by GitHub
parent 2018529add
commit e91b4f5c83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 543 additions and 627 deletions

View file

@ -17,6 +17,8 @@ import { ReactNode, useMemo } from 'react';
import clsx from 'clsx';
import _ from 'lodash';
import { AutomationTestingProps } from '@/types';
import { IconProps } from '@@/Icon';
import { DatatableHeader } from './DatatableHeader';
@ -32,7 +34,7 @@ import { TableRow } from './TableRow';
export interface Props<
D extends Record<string, unknown>,
TSettings extends BasicTableSettings = BasicTableSettings
> {
> extends AutomationTestingProps {
dataset: D[];
columns: TableOptions<D>['columns'];
renderTableSettings?(instance: TableInstance<D>): ReactNode;
@ -82,6 +84,7 @@ export function Datatable<D extends Record<string, unknown>>({
highlightedItemId,
noWidget,
getRowCanExpand,
'data-cy': dataCy,
}: Props<D>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
const enableRowSelection = getIsSelectionEnabled(
@ -156,6 +159,7 @@ export function Datatable<D extends Record<string, unknown>>({
emptyContentLabel={emptyContentLabel}
isLoading={isLoading}
onSortChange={handleSortChange}
data-cy={dataCy}
/>
<DatatableFooter

View file

@ -1,8 +1,11 @@
import { Row, Table as TableInstance } from '@tanstack/react-table';
import { AutomationTestingProps } from '@/types';
import { Table } from './Table';
interface Props<D extends Record<string, unknown>> {
interface Props<D extends Record<string, unknown>>
extends AutomationTestingProps {
tableInstance: TableInstance<D>;
renderRow(row: Row<D>): React.ReactNode;
onSortChange?(colId: string, desc: boolean): void;
@ -16,12 +19,13 @@ export function DatatableContent<D extends Record<string, unknown>>({
onSortChange,
isLoading,
emptyContentLabel,
'data-cy': dataCy,
}: Props<D>) {
const headerGroups = tableInstance.getHeaderGroups();
const pageRowModel = tableInstance.getPaginationRowModel();
return (
<Table>
<Table data-cy={dataCy}>
<thead>
{headerGroups.map((headerGroup) => (
<Table.HeaderRow<D>

View file

@ -1,6 +1,8 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types';
import { TableContainer } from './TableContainer';
import { TableActions } from './TableActions';
import { TableFooter } from './TableFooter';
@ -12,14 +14,19 @@ import { TableHeaderCell } from './TableHeaderCell';
import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow';
interface Props {
interface Props extends AutomationTestingProps {
className?: string;
}
function MainComponent({ children, className }: PropsWithChildren<Props>) {
function MainComponent({
children,
className,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
return (
<div className="table-responsive">
<table
data-cy={dataCy}
className={clsx(
'table-hover table-filters nowrap-cells table',
className

View file

@ -14,6 +14,9 @@ export function createSelectColumn<T>(): ColumnDef<T> {
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
onClick={(e) => {
e.stopPropagation();
}}
/>
),
cell: ({ row, table }) => (
@ -24,6 +27,8 @@ export function createSelectColumn<T>(): ColumnDef<T> {
onChange={row.getToggleSelectedHandler()}
disabled={!row.getCanSelect()}
onClick={(e) => {
e.stopPropagation();
if (e.shiftKey) {
const { rows, rowsById } = table.getRowModel();
const rowsToToggle = getRowRange(rows, row.id, lastSelectedId);

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useStore } from 'zustand';
import { useSearchBarState } from './SearchBar';
@ -27,3 +27,23 @@ export function useTableState<
[settings, search, setSearch]
);
}
export function useTableStateWithoutStorage(
defaultSortKey: string
): BasicTableSettings & {
setSearch: (search: string) => void;
search: string;
} {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const [sortBy, setSortBy] = useState({ id: defaultSortKey, desc: false });
return {
search,
setSearch,
pageSize,
setPageSize,
setSortBy: (id: string, desc: boolean) => setSortBy({ id, desc }),
sortBy,
};
}

View file

@ -0,0 +1,63 @@
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
export function AssociatedEdgeEnvironmentsSelector({
onChange,
value,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
}) {
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Available environments"
emptyContentLabel="No environment available"
query={{
types: EdgeTypes,
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
onChange([...value, env.Id], { type: 'add', value: env.Id });
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
hideEnvironmentIds={value}
/>
</div>
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Associated environments"
emptyContentLabel="No associated environment'"
query={{
types: EdgeTypes,
endpointIds: value,
}}
onClickRow={(env) => {
if (value.includes(env.Id)) {
onChange(
value.filter((id) => id !== env.Id),
{ type: 'remove', value: env.Id }
);
}
}}
/>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,117 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
Environment,
EnvironmentId,
} 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';
type DecoratedEnvironment = Environment & {
Tags: string[];
Group: string;
};
const columHelper = createColumnHelper<DecoratedEnvironment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor('Group', {
header: 'Group',
id: 'Group',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor((row) => row.Tags.join(','), {
header: 'Tags',
id: 'tags',
enableSorting: false,
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];
export function EdgeGroupAssociationTable({
title,
query,
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
hideEnvironmentIds = [],
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow: (env: Environment) => void;
hideEnvironmentIds?: EnvironmentId[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
...query,
});
const groupsQuery = useGroups({
enabled: environmentsQuery.environments.length > 0,
});
const tagsQuery = useTags({
enabled: environmentsQuery.environments.length > 0,
});
const environments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments
.filter((e) => !hideEnvironmentIds.includes(e.Id))
.map((env) => ({
...env,
Group:
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[
environmentsQuery.environments,
groupsQuery.data,
hideEnvironmentIds,
tagsQuery.data,
]
);
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
return (
<Datatable<DecoratedEnvironment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
onPageChange={setPage}
pageCount={Math.ceil(totalCount / tableState.pageSize)}
renderRow={(row) => (
<TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()}
onClick={() => onClickRow(row.original)}
/>
)}
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={totalCount}
/>
);
}

View file

@ -14,6 +14,7 @@ import {
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import {
refetchIfAnyOffline,
SortType,
useEnvironmentList,
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
@ -68,7 +69,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
'group',
[]
);
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy');
const [sortByFilter, setSortByFilter] = useHomePageFilter<
SortType | undefined
>('sortBy', 'Name');
const [sortByDescending, setSortByDescending] = useHomePageFilter(
'sortOrder',
false
@ -342,7 +345,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setConnectionTypes([]);
}
function sortOnchange(value: string) {
function sortOnchange(value?: 'Name' | 'Group' | 'Status') {
setSortByFilter(value);
setSortByButton(!!value);
}

View file

@ -6,6 +6,10 @@ import { useAgentVersionsList } from '../../environments/queries/useAgentVersion
import { EnvironmentStatus, PlatformType } from '../../environments/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { useGroups } from '../../environments/environment-groups/queries';
import {
SortOptions,
SortType,
} from '../../environments/queries/useEnvironmentList';
import { HomepageFilter } from './HomepageFilter';
import { SortbySelector } from './SortbySelector';
@ -17,7 +21,7 @@ const status = [
{ value: EnvironmentStatus.Down, label: 'Down' },
];
const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({
const sortByOptions = SortOptions.map((v) => ({
value: v,
label: v,
}));
@ -60,8 +64,8 @@ export function EnvironmentListFilters({
setAgentVersions: (value: string[]) => void;
agentVersions: string[];
sortByState: string;
sortOnChange: (value: string) => void;
sortByState?: SortType;
sortOnChange: (value: SortType) => void;
sortOnDescending: () => void;
sortByDescending: boolean;

View file

@ -3,16 +3,18 @@ import clsx from 'clsx';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
import { SortType } from '../../environments/queries/useEnvironmentList';
import styles from './SortbySelector.module.css';
interface Props {
filterOptions: Option<string>[];
onChange: (value: string) => void;
filterOptions: Option<SortType>[];
onChange: (value: SortType) => void;
onDescending: () => void;
placeHolder: string;
sortByDescending: boolean;
sortByButton: boolean;
value: string;
value?: SortType;
}
export function SortbySelector({
@ -30,7 +32,7 @@ export function SortbySelector({
<PortainerSelect
placeholder={placeHolder}
options={filterOptions}
onChange={(option) => onChange(option || '')}
onChange={(option: SortType) => onChange(option || '')}
isClearable
value={value}
/>

View file

@ -11,7 +11,7 @@ import { Link } from '@@/Link';
import { useTableState } from '@@/datatables/useTableState';
import { isBE } from '../../feature-flags/feature-flags.service';
import { refetchIfAnyOffline } from '../queries/useEnvironmentList';
import { isSortType, refetchIfAnyOffline } from '../queries/useEnvironmentList';
import { columns } from './columns';
import { EnvironmentListItem } from './types';
@ -36,7 +36,7 @@ export function EnvironmentsDatatable({
excludeSnapshots: true,
page: page + 1,
pageLimit: tableState.pageSize,
sort: tableState.sortBy.id,
sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined,
order: tableState.sortBy.desc ? 'desc' : 'asc',
},
{ enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline }

View file

@ -0,0 +1,61 @@
import { EnvironmentId } from '../../types';
import { GroupAssociationTable } from './GroupAssociationTable';
export function AssociatedEnvironmentsSelector({
onChange,
value,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
}) {
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<GroupAssociationTable
title="Available environments"
emptyContentLabel="No environment available"
query={{
groupIds: [1],
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
onChange([...value, env.Id], { type: 'add', value: env.Id });
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
/>
</div>
<div className="w-1/2">
<GroupAssociationTable
title="Associated environments"
emptyContentLabel="No associated environment'"
query={{
endpointIds: value,
}}
onClickRow={(env) => {
if (value.includes(env.Id)) {
onChange(
value.filter((id) => id !== env.Id),
{ type: 'remove', value: env.Id }
);
}
}}
/>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,68 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
const columHelper = createColumnHelper<Environment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];
export function GroupAssociationTable({
title,
query,
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow?: (env: Environment) => void;
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
search: tableState.search,
sort: tableState.sortBy.id as 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
...query,
});
const { environments } = environmentsQuery;
return (
<Datatable<Environment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
onPageChange={setPage}
pageCount={Math.ceil(environmentsQuery.totalCount / tableState.pageSize)}
renderRow={(row) => (
<TableRow<Environment>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={environmentsQuery.totalCount}
/>
);
}

View file

@ -8,9 +8,11 @@ import { queryKeys } from './queries/query-keys';
export function useGroups<T = EnvironmentGroup[]>({
select,
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
enabled = true,
}: { select?: (group: EnvironmentGroup[]) => T; enabled?: boolean } = {}) {
return useQuery(queryKeys.base(), getGroups, {
select,
enabled,
});
}

View file

@ -60,7 +60,10 @@ export async function getEnvironments(
query = {},
}: GetEnvironmentsOptions = { query: {} }
) {
if (query.tagIds && query.tagIds.length === 0) {
if (
(query.tagIds && query.tagIds.length === 0) ||
(query.endpointIds && query.endpointIds.length === 0)
) {
return {
totalCount: 0,
value: <Environment[]>[],

View file

@ -12,10 +12,16 @@ import { queryKeys } from './query-keys';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export const SortOptions = ['Name', 'Group', 'Status'] as const;
export type SortType = (typeof SortOptions)[number];
export function isSortType(value: string): value is SortType {
return SortOptions.includes(value as SortType);
}
export type Query = EnvironmentsQueryParams & {
page?: number;
pageLimit?: number;
sort?: string;
sort?: SortType;
order?: 'asc' | 'desc';
};