feat(environment group) detail view update v1 [c9s-206] (#2722)

Last system-test failure is also on dev
This commit is contained in:
Josiah Clumont
2026-06-02 16:59:18 +12:00
committed by GitHub
parent 742551e592
commit 484af3c2c8
26 changed files with 1203 additions and 311 deletions
+1
View File
@@ -4,6 +4,7 @@ dist
portainer-checksum.txt
api/cmd/portainer/portainer*
storybook-static
debug-storybook.log
.tmp
**/.vscode/settings.json
**/.vscode/tasks.json
@@ -13,6 +13,50 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
)
func computeGroupSizeInfo(endpointGroups []portainer.EndpointGroup, endpoints []portainer.Endpoint) (map[portainer.EndpointGroupID]int, map[portainer.EndpointGroupID]endpointGroupTypeInfo) {
groupSet := set.Set[portainer.EndpointGroupID]{}
for i := range endpointGroups {
groupSet[endpointGroups[i].ID] = true
}
countMap := make(map[portainer.EndpointGroupID]int)
typeInfoMap := make(map[portainer.EndpointGroupID]endpointGroupTypeInfo)
for _, endpoint := range endpoints {
if _, ok := groupSet[endpoint.GroupID]; !ok {
continue
}
countMap[endpoint.GroupID]++
typeInfo := typeInfoMap[endpoint.GroupID]
if endpointutils.IsKubernetesEndpoint(&endpoint) {
typeInfo.Kubernetes++
} else if endpoint.ContainerEngine == portainer.ContainerEnginePodman {
typeInfo.Podman++
} else {
typeInfo.Docker++
}
typeInfoMap[endpoint.GroupID] = typeInfo
}
for groupID, typeInfo := range typeInfoMap {
var bits int
if typeInfo.Docker > 0 {
bits |= 1
}
if typeInfo.Kubernetes > 0 {
bits |= 2
}
if typeInfo.Podman > 0 {
bits |= 4
}
typeInfo.Mixed = bits&(bits-1) != 0
typeInfoMap[groupID] = typeInfo
}
return countMap, typeInfoMap
}
type endpointGroupTypeInfo struct {
Docker int `json:"Docker"`
Kubernetes int `json:"Kubernetes"`
@@ -83,50 +127,11 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request
if len(endpointGroups) == 0 {
return response.JSON(w, []portainer.EndpointGroup{})
}
endpointGroupSet := set.Set[portainer.EndpointGroupID]{}
if includeSize {
for i := range endpointGroups {
endpointGroupSet[endpointGroups[i].ID] = true
}
}
var endpointGroupCountMap map[portainer.EndpointGroupID]int
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo
if includeSize {
endpointGroupCountMap = make(map[portainer.EndpointGroupID]int)
endpointGroupTypeInfoMap = make(map[portainer.EndpointGroupID]endpointGroupTypeInfo)
for _, endpoint := range endpoints {
if _, ok := endpointGroupSet[endpoint.GroupID]; !ok {
continue
}
endpointGroupCountMap[endpoint.GroupID]++
typeInfo := endpointGroupTypeInfoMap[endpoint.GroupID]
if endpointutils.IsKubernetesEndpoint(&endpoint) {
typeInfo.Kubernetes++
} else if endpoint.ContainerEngine == "podman" {
typeInfo.Podman++
} else {
typeInfo.Docker++
}
endpointGroupTypeInfoMap[endpoint.GroupID] = typeInfo
}
for groupID, typeInfo := range endpointGroupTypeInfoMap {
var bits int
if typeInfo.Docker > 0 {
bits |= 1
}
if typeInfo.Kubernetes > 0 {
bits |= 2
}
if typeInfo.Podman > 0 {
bits |= 4
}
typeInfo.Mixed = bits&(bits-1) != 0
endpointGroupTypeInfoMap[groupID] = typeInfo
}
endpointGroupCountMap, endpointGroupTypeInfoMap = computeGroupSizeInfo(endpointGroups, endpoints)
}
endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups))
@@ -155,7 +155,7 @@ func TestHandler_endpointGroupList(t *testing.T) {
ID: 5,
GroupID: groups[0].ID,
Type: portainer.DockerEnvironment,
ContainerEngine: "podman",
ContainerEngine: portainer.ContainerEnginePodman,
}
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
+3
View File
@@ -252,6 +252,9 @@ angular
id: {
type: 'int',
},
tab: {
dynamic: true,
},
},
};
+1
View File
@@ -126,6 +126,7 @@ export const ngModule = angular
'onReload',
'reload',
'id',
'showTitle',
])
)
.component(
+3
View File
@@ -0,0 +1,3 @@
export * from './axios';
// eslint-disable-next-line no-restricted-exports
export { default } from './axios';
@@ -0,0 +1,166 @@
import { Meta } from '@storybook/react-webpack5';
import { useState } from 'react';
import { expect, within } from 'storybook/test';
import { Server, Cloud, Code } from 'lucide-react';
import { Icon } from '@@/Icon';
import { GroupSortTableHeader, SortOption } from './GroupSortTableHeader';
export default {
component: GroupSortTableHeader,
title: 'Components/Tables/GroupSortTableHeader',
} as Meta;
const availableGroups = [
{ key: 'Production', count: 12, icon: <Icon icon={Server} /> },
{ key: 'Staging', count: 5, icon: <Icon icon={Cloud} /> },
{ key: 'Development', count: 8, icon: <Icon icon={Code} /> },
];
export function Interactive() {
const [sortBy, setSortBy] = useState('name');
const [searchTerm, setSearchTerm] = useState('');
const [groupFilter, setGroupFilter] = useState<string | null>(null);
const sortOptions: SortOption<string>[] = [
{ key: 'name', label: 'Name' },
{
key: 'group',
label: 'Group',
dropdown: {
options: availableGroups,
selected: groupFilter,
onSelect: setGroupFilter,
},
},
{ key: 'status', label: 'Status' },
];
return (
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={sortOptions}
searchPlaceholder="Search environments..."
searchDataCy="group-sort-search"
/>
);
}
export function MixedButtonTypes() {
const [sortBy, setSortBy] = useState('Name');
const [searchTerm, setSearchTerm] = useState('');
const [platformFilter, setPlatformFilter] = useState<string | null>(null);
const [govFilter, setGovFilter] = useState<string | null>(null);
const sortOptions: SortOption<string>[] = [
{ key: 'Name', label: 'Name' },
{
key: 'Platform',
label: 'Platform',
dropdown: {
options: [
{ key: 'Docker', count: 5 },
{ key: 'Kubernetes', count: 3 },
{ key: 'Podman', count: 1 },
],
selected: platformFilter,
onSelect: setPlatformFilter,
},
},
{
key: 'Governance',
label: 'Governance',
dropdown: {
options: [
{ key: 'Ungoverned', count: 2 },
{ key: 'Policy Attached', count: 7 },
],
selected: govFilter,
onSelect: setGovFilter,
},
},
];
return (
<div className="flex flex-col gap-4">
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={sortOptions}
searchPlaceholder="Filter groups..."
actionButton={
<button
type="button"
className="rounded bg-blue-8 px-3 py-1.5 text-sm text-white"
>
Add Group
</button>
}
/>
<div className="p-4 text-sm">
Sort: <strong>{sortBy}</strong> | Platform:{' '}
<strong>{platformFilter ?? 'All'}</strong> | Governance:{' '}
<strong>{govFilter ?? 'All'}</strong>
</div>
</div>
);
}
export function MultipleBadgesPersist() {
const [sortBy, setSortBy] = useState('Governance');
const [searchTerm, setSearchTerm] = useState('');
const sortOptions: SortOption<string>[] = [
{ key: 'Name', label: 'Name' },
{
key: 'Platform',
label: 'Platform',
dropdown: {
options: [{ key: 'Docker', count: 5 }],
selected: 'Docker',
onSelect: () => {},
},
},
{
key: 'Governance',
label: 'Governance',
dropdown: {
options: [{ key: 'Ungoverned', count: 2 }],
selected: 'Ungoverned',
onSelect: () => {},
},
},
];
return (
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={sortOptions}
/>
);
}
MultipleBadgesPersist.play = async ({
canvasElement,
}: {
canvasElement: HTMLElement;
}) => {
const canvas = within(canvasElement);
// Governance is the active sort — its badge should show
const govBtn = canvas.getByRole('button', { name: /Governance/i });
await expect(govBtn).toHaveTextContent('Ungoverned');
// Platform is inactive — its badge should ALSO show
const platformBtn = canvas.getByRole('button', { name: /Platform/i });
await expect(platformBtn).toHaveTextContent('Docker');
};
@@ -0,0 +1,210 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GroupSortTableHeader, SortOption } from './GroupSortTableHeader';
const defaultGroups = [
{ key: 'Docker', count: 3 },
{ key: 'Kubernetes', count: 2 },
];
const defaultSortOptions: SortOption<string>[] = [
{ key: 'Name', label: 'Name' },
{
key: 'Platform',
label: 'Platform',
dropdown: {
options: defaultGroups,
selected: null,
onSelect: vi.fn(),
},
},
{ key: 'Health', label: 'Health' },
];
function renderHeader(
overrides: Partial<
React.ComponentProps<typeof GroupSortTableHeader<string>>
> = {}
) {
const props = {
sortBy: 'Name' as string,
onSortChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),
sortOptions: defaultSortOptions,
...overrides,
};
return {
...render(<GroupSortTableHeader {...props} />),
props,
};
}
describe('GroupSortTableHeader', () => {
test('renders all sort option buttons', () => {
renderHeader();
expect(screen.getByRole('button', { name: /Name/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Platform/i })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Health/i })).toBeInTheDocument();
});
test('plain button calls onSortChange when clicked', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderHeader({ sortBy: 'Platform', onSortChange });
await user.click(screen.getByRole('button', { name: /^Name$/i }));
expect(onSortChange).toHaveBeenCalledWith('Name');
});
test('dropdown button opens menu with options', async () => {
const user = userEvent.setup();
renderHeader({ sortBy: 'Platform' });
await user.click(screen.getByRole('button', { name: /Platform/i }));
expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible();
expect(screen.getByRole('menuitem', { name: /Docker/ })).toBeVisible();
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
});
test('clicking dropdown button does not change the active sort', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderHeader({ sortBy: 'Name', onSortChange });
await user.click(screen.getByRole('button', { name: /Platform/i }));
expect(onSortChange).not.toHaveBeenCalled();
});
test('active dropdown filter is shown as a badge', () => {
const options: SortOption<string>[] = [
{
key: 'Platform',
label: 'Platform',
dropdown: {
options: defaultGroups,
selected: 'Docker',
onSelect: vi.fn(),
},
},
];
renderHeader({ sortBy: 'Platform', sortOptions: options });
const btn = screen.getByRole('button', { name: /Platform/i });
expect(btn).toHaveTextContent('Docker');
});
test('dropdown shows counts for each option', async () => {
const user = userEvent.setup();
renderHeader({ sortBy: 'Platform' });
await user.click(screen.getByRole('button', { name: /Platform/i }));
const menu = screen.getByRole('menu', { name: /Platform/i });
expect(menu).toHaveTextContent('3');
expect(menu).toHaveTextContent('2');
});
test('search input renders with the correct placeholder', () => {
renderHeader();
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument();
});
test('renders custom search placeholder', () => {
renderHeader({ searchPlaceholder: 'Search environments...' });
expect(
screen.getByPlaceholderText('Search environments...')
).toBeInTheDocument();
});
test('renders action button when provided', () => {
renderHeader({
actionButton: <button type="button">Add item</button>,
});
expect(
screen.getByRole('button', { name: /Add item/i })
).toBeInTheDocument();
});
test('shows ascending arrow on active sort button when sortAsc is true', () => {
renderHeader({ sortBy: 'Name', sortAsc: true });
const activeBtn = screen.getByRole('button', { name: /^Name$/i });
expect(
activeBtn.querySelector('.lucide-arrow-down-a-z')
).toBeInTheDocument();
expect(
activeBtn.querySelector('.lucide-arrow-down-z-a')
).not.toBeInTheDocument();
});
test('shows descending arrow on active sort button when sortAsc is false', () => {
renderHeader({ sortBy: 'Name', sortAsc: false });
const activeBtn = screen.getByRole('button', { name: /^Name$/i });
expect(
activeBtn.querySelector('.lucide-arrow-down-z-a')
).toBeInTheDocument();
expect(
activeBtn.querySelector('.lucide-arrow-down-a-z')
).not.toBeInTheDocument();
});
test('defaults to ascending arrow on active sort button when sortAsc is undefined', () => {
renderHeader({ sortBy: 'Name' });
const activeBtn = screen.getByRole('button', { name: /^Name$/i });
expect(
activeBtn.querySelector('.lucide-arrow-down-a-z')
).toBeInTheDocument();
expect(
activeBtn.querySelector('.lucide-arrow-down-z-a')
).not.toBeInTheDocument();
});
test('does not show sort direction arrow on inactive sort buttons', () => {
renderHeader({ sortBy: 'Name', sortAsc: true });
const inactiveBtn = screen.getByRole('button', { name: /^Health$/i });
expect(
inactiveBtn.querySelector('.lucide-arrow-down-a-z')
).not.toBeInTheDocument();
expect(
inactiveBtn.querySelector('.lucide-arrow-down-z-a')
).not.toBeInTheDocument();
});
test('does not render clear button when onClear is not provided', () => {
renderHeader();
expect(
screen.queryByRole('button', { name: /Clear all sort options/i })
).not.toBeInTheDocument();
});
test('renders clear button and calls onClear when clicked', async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderHeader({ onClear });
const clearBtn = screen.getByRole('button', {
name: /Clear all sort options/i,
});
expect(clearBtn).toBeInTheDocument();
await user.click(clearBtn);
expect(onClear).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,165 @@
import React from 'react';
import clsx from 'clsx';
import { ArrowDownAZ, ArrowDownZA, X } from 'lucide-react';
import { SearchBar } from '@@/datatables/SearchBar';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { DropdownMenu, DropdownOption } from '../DropdownMenu/DropdownMenu';
export interface SortOption<TSortKey extends string> {
key: TSortKey;
label: string;
dropdown?: {
options: DropdownOption[];
selected: string | null;
onSelect: (value: string | null) => void;
};
}
interface Props<TSortKey extends string> {
sortBy: TSortKey;
sortAsc?: boolean;
onSortChange: (key: TSortKey) => void;
onClear?: () => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortOptions: SortOption<TSortKey>[];
searchPlaceholder?: string;
actionButton?: React.ReactNode;
searchDataCy?: string;
}
const clearBtnClasses =
'inline-flex items-center justify-center rounded-md border-0 bg-transparent p-1.5 transition-colors ' +
'text-gray-8 hover:bg-gray-4 hover:text-gray-11 ' +
'th-dark:text-gray-5 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-white ' +
'th-highcontrast:border th-highcontrast:border-solid th-highcontrast:border-white ' +
'th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black';
const baseBtn =
'px-4 py-1.5 text-xs align-middle font-medium transition-colors';
const activeBtn =
'z-10 border-none rounded-md font-medium ' +
'bg-white text-gray-8 ' +
'th-dark:bg-gray-iron-10 th-dark:text-white ' +
'th-highcontrast:bg-white th-highcontrast:text-black';
const inactiveBtn =
'text-muted border-none rounded-md ' +
'bg-gray-4 hover:bg-gray-2 ' +
'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9 ' +
'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black';
export function GroupSortTableHeader<TSortKey extends string>({
sortBy,
sortAsc,
onSortChange,
onClear,
searchTerm,
onSearchChange,
sortOptions,
searchPlaceholder = 'Filter...',
actionButton,
searchDataCy,
}: Props<TSortKey>) {
return (
<div
className={clsx(
'flex items-center justify-between gap-3 px-5 py-3',
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10'
)}
>
<span
className="text-xs font-semibold tracking-wider text-gray-11 th-highcontrast:text-white th-dark:text-white"
data-cy="sort-by-label"
>
SORT BY:
</span>
<div
className={clsx(
'flex',
'bg-gray-4 th-highcontrast:bg-black th-dark:bg-gray-iron-11',
'gap-2 rounded-md p-1 th-highcontrast:border th-highcontrast:border-solid th-highcontrast:border-white'
)}
role="group"
>
{sortOptions.map((option, index) => {
const isActive = sortBy === option.key;
const isFirst = index === 0;
const isLast = index === sortOptions.length - 1;
const cornerClasses = clsx(
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md'
);
const btnClasses = clsx(
baseBtn,
isActive ? activeBtn : inactiveBtn,
cornerClasses
);
if (option.dropdown) {
return (
<DropdownMenu
key={option.key}
label={option.label}
options={option.dropdown.options}
selected={option.dropdown.selected}
onSelect={option.dropdown.onSelect}
badge={option.dropdown.selected}
className={btnClasses}
data-cy={`sort-by-${option.key.toLowerCase()}-button`}
/>
);
}
return (
<button
key={option.key}
type="button"
onClick={() => onSortChange(option.key)}
className={clsx(btnClasses, 'inline-flex items-center gap-1')}
data-cy={`sort-by-${option.key.toLowerCase()}-button`}
>
{option.label}
{isActive &&
(sortAsc === false ? (
<ArrowDownZA className="h-3 w-3" aria-hidden="true" />
) : (
<ArrowDownAZ className="h-3 w-3" aria-hidden="true" />
))}
</button>
);
})}
</div>
{onClear && (
<TooltipWithChildren message="Clear all sort options" position="top">
<button
type="button"
onClick={onClear}
className={clearBtnClasses}
aria-label="Clear all sort options"
data-cy="clear-sort-options-button"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</TooltipWithChildren>
)}
<div className="ml-auto flex items-center gap-2">
<SearchBar
value={searchTerm}
placeholder={searchPlaceholder}
onChange={onSearchChange}
data-cy={searchDataCy || ''}
className={clsx(
'rounded-md border border-solid border-gray-4',
'bg-white py-2 pl-9 pr-3 text-sm text-gray-10 placeholder-gray-6',
'focus:border-blue-6 focus:outline-none',
'th-dark:border-gray-7 th-dark:bg-gray-iron-10 th-dark:text-white th-dark:placeholder-gray-7',
'th-highcontrast:border-white th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:placeholder-gray-4 th-highcontrast:focus:border-blue-8'
)}
/>
{actionButton}
</div>
</div>
);
}
+2 -5
View File
@@ -1,11 +1,8 @@
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
import { UISrefProps, useSref } from '@uirouter/react';
interface Props {
title?: string;
target?: AnchorHTMLAttributes<HTMLAnchorElement>['target'];
rel?: AnchorHTMLAttributes<HTMLAnchorElement>['rel'];
'data-cy': AnchorHTMLAttributes<HTMLAnchorElement>['data-cy'];
interface Props extends AnchorHTMLAttributes<HTMLAnchorElement> {
'data-cy': string;
}
export function Link({
+25 -18
View File
@@ -12,15 +12,17 @@ import { HeaderContainer } from './HeaderContainer';
import { HeaderTitle } from './HeaderTitle';
import { PageTitle } from './PageTitle';
export type Breadcrumb = Crumb | string;
export interface Props {
interface Props {
id?: string;
reload?: boolean;
loading?: boolean;
onReload?(): Promise<void> | void;
breadcrumbs?: Breadcrumb[] | string;
breadcrumbs?: (Crumb | string)[] | string;
title?: string;
/** Render the visible page title row. Defaults to true when title is provided.
* Set to false on screens that display the title via another component (e.g.
* `ResourceDetailHeader`) to avoid showing it twice. */
showTitle?: boolean;
}
export function PageHeader({
@@ -30,6 +32,7 @@ export function PageHeader({
reload,
loading,
onReload,
showTitle = !!title,
children,
}: PropsWithChildren<Props>) {
const router = useRouter();
@@ -41,22 +44,26 @@ export function PageHeader({
<HeaderTitle />
</HeaderContainer>
{title && (
{showTitle && title && (
<PageTitle title={title}>
{reload && (
<Button
color="none"
size="large"
onClick={onClickedRefresh}
className="m-0 p-0 focus:text-inherit"
disabled={loading}
title="Refresh page"
data-cy="refresh-page-button"
>
<RefreshCw className="icon" />
</Button>
{(reload || children) && (
<div className="ml-auto flex items-center gap-2">
{reload && (
<Button
color="none"
size="large"
onClick={onClickedRefresh}
className="m-0 p-0 focus:text-inherit"
disabled={loading}
title="Refresh page"
data-cy="refresh-page-button"
>
<RefreshCw className="icon" />
</Button>
)}
{children}
</div>
)}
{children}
</PageTitle>
)}
</>
@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
import clsx from 'clsx';
import { Widget } from '@@/Widget';
import { Alert } from '@@/Alert';
interface Props {
icon: ReactNode;
@@ -16,6 +17,9 @@ interface Props {
rightInfo?: ReactNode;
actionBar?: ReactNode;
isLoading?: boolean;
errorMessage?: string;
containerClassName?: string;
widgetClassName?: string;
}
@@ -30,27 +34,38 @@ export function ResourceDetailHeader({
description,
rightInfo,
actionBar,
isLoading,
errorMessage,
containerClassName = 'flex items-center gap-4 p-6',
widgetClassName = 'widget-body',
}: Props) {
return (
<Widget className={widgetClassName}>
<div className={containerClassName}>
<HeaderIcon
icon={icon}
iconBackgroundClassName={iconBackgroundClassName}
/>
<HeaderInfo
subtitleLabel={subtitleLabel}
subtitleClassName={subtitleClassName}
title={title}
badge={badge}
description={description}
/>
{rightInfo}
</div>
<Widget.Body loading={isLoading}>
{errorMessage && (
<Alert color="error" title="Error">
{errorMessage}
</Alert>
)}
{!errorMessage && (
<div className={containerClassName}>
<HeaderIcon
icon={icon}
iconBackgroundClassName={iconBackgroundClassName}
/>
<HeaderInfo
subtitleLabel={subtitleLabel}
subtitleClassName={subtitleClassName}
title={title}
badge={badge}
description={description}
/>
{rightInfo}
</div>
)}
</Widget.Body>
{actionBar}
{!isLoading && !errorMessage && actionBar}
</Widget>
);
}
@@ -24,14 +24,10 @@ export function TableContainer({
}
return (
<div className="row">
<div className="col-sm-12">
<div className="datatable">
<Widget aria-label={ariaLabel} id={id}>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>
</div>
<div className="datatable">
<Widget aria-label={ariaLabel} id={id}>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>
);
}
@@ -38,7 +38,7 @@ export function RegistryBadge({ registryId, children, dataCy }: Props) {
to="portainer.registries.registry"
params={{ id: registryId }}
className="!text-inherit"
data-cy={dataCy ? `${dataCy}-link` : undefined}
data-cy={dataCy ? `${dataCy}-link` : 'registry-badge-link'}
>
{Name}
</Link>
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { http, HttpResponse, DefaultBodyType } from 'msw';
@@ -7,6 +7,7 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { CreateGroupView } from './CreateGroupView';
@@ -21,7 +22,11 @@ vi.mock('@/portainer/services/notifications', () => ({
function renderCreateGroupView({
onMutationError,
}: { onMutationError?(): void } = {}) {
availableEnvironments = [] as ReturnType<typeof createMockEnvironment>[],
}: {
onMutationError?(): void;
availableEnvironments?: ReturnType<typeof createMockEnvironment>[];
} = {}) {
// Set up default mocks
server.use(
http.get('/api/tags', () =>
@@ -30,14 +35,24 @@ function renderCreateGroupView({
{ ID: 2, Name: 'staging' },
])
),
http.get('/api/endpoints', () =>
HttpResponse.json([], {
headers: {
'x-total-count': '0',
'x-total-available': '0',
},
})
)
http.get('/api/endpoints', ({ request }) => {
const url = new URL(request.url);
const groupIds = [
...url.searchParams.getAll('groupIds'),
...url.searchParams.getAll('groupIds[]'),
];
if (groupIds.includes('1') && availableEnvironments.length > 0) {
return HttpResponse.json(availableEnvironments, {
headers: {
'x-total-count': String(availableEnvironments.length),
'x-total-available': String(availableEnvironments.length),
},
});
}
return HttpResponse.json([], {
headers: { 'x-total-count': '0', 'x-total-available': '0' },
});
})
);
const Wrapped = withTestQueryProvider(
@@ -263,4 +278,110 @@ describe('CreateGroupView', () => {
expect(submitButton).toBeDisabled();
});
});
describe('Environment selection via drawer', () => {
it('should include environments selected in the drawer in the POST payload', async () => {
let requestBody: DefaultBodyType;
const env = createMockEnvironment({ Id: 42, Name: 'my-test-env' });
server.use(
http.post('/api/endpoint_groups', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'test-group',
TagIds: [],
Policies: [],
});
})
);
const user = userEvent.setup();
renderCreateGroupView({ availableEnvironments: [env] });
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
// Open the add environments drawer
const addBtn = await screen.findByTestId('add-environments-button');
await user.click(addBtn);
// Wait for the environment to appear in the drawer, then select via checkbox
await screen.findByText('my-test-env');
const drawerTable = screen.getByTestId('add-environments-drawer-table');
const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox');
await user.click(drawerCheckboxes[1]); // [0] = select-all header, [1] = first row
// Confirm the selection
const confirmBtn = screen.getByTestId('add-environments-confirm-button');
await waitFor(() => expect(confirmBtn).toBeEnabled());
await user.click(confirmBtn);
// Submit the form
const submitBtn = screen.getByRole('button', { name: /Create/i });
await waitFor(() => expect(submitBtn).toBeEnabled());
await user.click(submitBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
Name: 'test-group',
AssociatedEndpoints: [42],
});
});
});
it('should allow removing a selected environment before submitting', async () => {
let requestBody: DefaultBodyType;
const env = createMockEnvironment({ Id: 42, Name: 'removable-env' });
server.use(
http.post('/api/endpoint_groups', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'test-group',
TagIds: [],
Policies: [],
});
})
);
const user = userEvent.setup();
renderCreateGroupView({ availableEnvironments: [env] });
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
// Add the environment via drawer
const addBtn = await screen.findByTestId('add-environments-button');
await user.click(addBtn);
await screen.findByText('removable-env');
const drawerTable = screen.getByTestId('add-environments-drawer-table');
const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox');
await user.click(drawerCheckboxes[1]);
const confirmBtn = screen.getByTestId('add-environments-confirm-button');
await waitFor(() => expect(confirmBtn).toBeEnabled());
await user.click(confirmBtn);
// Environment now appears in the associated list — select its row checkbox and remove it
await screen.findByText('removable-env');
const assocCheckboxes = screen.getAllByRole('checkbox');
await user.click(assocCheckboxes[assocCheckboxes.length - 1]);
const removeBtn = await screen.findByTestId('remove-environments-button');
await waitFor(() => expect(removeBtn).toBeEnabled());
await user.click(removeBtn);
// Submit — payload should have no environments
const submitBtn = screen.getByRole('button', { name: /Create/i });
await waitFor(() => expect(submitBtn).toBeEnabled());
await user.click(submitBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
Name: 'test-group',
AssociatedEndpoints: [],
});
});
});
});
});
@@ -30,19 +30,17 @@ export function CreateGroupView() {
]}
/>
<div className="row pb-20">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Create"
submitLoadingLabel="Creating..."
/>
</Widget.Body>
</Widget>
</div>
<div className="mx-4 pb-20">
<Widget>
<Widget.Body>
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Create"
submitLoadingLabel="Creating..."
/>
</Widget.Body>
</Widget>
</div>
</>
);
@@ -31,6 +31,28 @@ vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
}));
vi.mock('@@/Link', () => ({
Link: ({
children,
className,
...props
}: {
children: React.ReactNode;
className?: string;
}) => (
<a className={className} {...props}>
{children}
</a>
),
}));
vi.mock('@@/modals/confirm', () => {
const confirmFn = vi.fn(async () => true);
return {
confirm: confirmFn,
};
});
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
@@ -153,9 +175,8 @@ describe('EditGroupView', () => {
it('should render the page header with correct title', async () => {
renderEditGroupView();
expect(
await screen.findByText('Environment group details')
).toBeVisible();
// The component should render without errors and breadcrumbs should be visible
expect(await screen.findByText('Groups')).toBeVisible();
});
it('should render breadcrumbs with link to Groups', async () => {
@@ -164,10 +185,17 @@ describe('EditGroupView', () => {
expect(await screen.findByText('Groups')).toBeVisible();
});
it('should render group name in breadcrumbs after loading', async () => {
it('should load and display group data', async () => {
renderEditGroupView();
expect(await screen.findByText('Test Group')).toBeVisible();
// Wait for the form to load and populate with group data
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
// Verify the group data is loaded
expect(nameInput).toHaveValue('Test Group');
});
it('should render the Update button', async () => {
@@ -215,16 +243,14 @@ describe('EditGroupView', () => {
expect(descriptionInput).toHaveValue('Test description');
});
it('should show Associated environments section for non-unassigned groups', async () => {
it('should show Environments tab for non-unassigned groups', async () => {
renderEditGroupView();
// Wait for form to load
await screen.findByLabelText(/Name/i);
// Check that at least one "Associated environments" text exists (section + table title)
const elements = screen.getAllByText(/Associated environments/i);
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeVisible();
// The Environments tab should be visible
expect(screen.getByText('Environments')).toBeVisible();
});
});
@@ -242,16 +268,6 @@ describe('EditGroupView', () => {
).toBeVisible();
});
it('should show error alert with Error title', async () => {
renderEditGroupView({ groupData: null });
// Wait for the error alert to appear by finding the error message
await screen.findByText(/Failed to load group details/i);
// Check that the Error title is shown
expect(screen.getByText('Error')).toBeVisible();
});
it('should NOT show the form when group fetch fails', async () => {
renderEditGroupView({ groupData: null });
@@ -306,7 +322,7 @@ describe('EditGroupView', () => {
// Verify the request URL and body.
await waitFor(() => {
expect(requestUrl).toBe('/api/endpoint_groups/2');
expect(requestBody).toEqual({
expect(requestBody).toMatchObject({
Name: 'Updated Group',
Description: 'Test description',
TagIDs: [1],
@@ -435,25 +451,8 @@ describe('EditGroupView', () => {
});
});
describe('Associated environments', () => {
it('should display initially associated environments', async () => {
renderEditGroupView({
associatedEnvironments: [
{ ...mockEnvironment, Id: 1, Name: 'Env 1' } as Partial<Environment>,
{ ...mockEnvironment, Id: 2, Name: 'Env 2' } as Partial<Environment>,
],
});
// Wait for the form to load
await screen.findByLabelText(/Name/i);
// Check that at least one "Associated environments" text exists (section + table title)
const elements = screen.getAllByText(/Associated environments/i);
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeVisible();
});
it('should NOT include AssociatedEndpoints in update payload (backend preserves associations)', async () => {
describe('Update payload', () => {
it('should include AssociatedEndpoints in update payload', async () => {
let requestBody: DefaultBodyType;
server.use(
@@ -482,10 +481,46 @@ describe('EditGroupView', () => {
await user.click(submitButton);
// Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
// Verify AssociatedEndpoints is present in the payload
await waitFor(() => {
expect(requestBody).not.toHaveProperty('AssociatedEndpoints');
expect(requestBody).toHaveProperty('AssociatedEndpoints');
});
});
});
describe('Delete flow', () => {
it('should render the delete button', async () => {
renderEditGroupView();
// Wait for form to load
await screen.findByLabelText(/Name/i);
// Delete button should be visible
expect(screen.getByRole('button', { name: /Delete/i })).toBeVisible();
});
it('should hide the delete button when group data is missing', async () => {
renderEditGroupView({ groupData: null });
// Wait for the header error state to appear
await screen.findByText(/Failed to load group details/i);
// Delete lives inside GroupHeader's action bar, which is suppressed
// when the group hasn't loaded — there's nothing to act on.
expect(
screen.queryByRole('button', { name: /Delete/i })
).not.toBeInTheDocument();
});
it('should have correct data-cy attribute', async () => {
renderEditGroupView();
// Wait for form to load
await screen.findByLabelText(/Name/i);
// The Delete button now lives inside GroupHeader's action bar
const deleteButton = screen.getByRole('button', { name: /Delete/i });
expect(deleteButton).toHaveAttribute('data-cy', 'group-header-delete');
});
});
});
@@ -1,118 +1,91 @@
import { useMemo, useState } from 'react';
import { Box } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { useMemo } from 'react';
import { FormikHelpers } from 'formik';
import { useQueryClient } from '@tanstack/react-query';
import { useIdParam } from '@/react/hooks/useIdParam';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam';
import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { Alert } from '@@/Alert';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Tab, WidgetTabs, useCurrentTabIndex } from '@@/Widget/WidgetTabs';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals/Modal';
import { buildConfirmButton } from '@@/modals/utils';
import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
import { queryKeys } from '../queries/query-keys';
import { GroupForm, GroupFormValues } from '../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
import { EnvironmentsTab } from './tabs/EnvironmentsTab';
import { GroupHeader } from './GroupHeader';
export function EditGroupView() {
const groupId = useIdParam();
const router = useRouter();
const queryClient = useQueryClient();
const groupQuery = useGroup(groupId);
const isUnassignedGroup = groupId === 1;
const updateMutation = useUpdateGroupMutation();
const groupId = useIdParam();
const deleteMutation = useDeleteEnvironmentGroupMutation();
const initialValues: GroupFormValues = useMemo(
() => ({
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [],
}),
[groupQuery.data]
const [addEnvsDrawerOpen, setAddEnvsDrawerOpen] = useState(false);
const groupQuery = useGroup(
deleteMutation.isLoading || deleteMutation.isSuccess ? undefined : groupId
);
const group = groupQuery.data;
const groupName = group?.Name ?? 'Environment group';
async function handleDeleteGroup() {
const confirmed = await confirm({
title: 'Delete Environment Group',
modalType: ModalType.Destructive,
message: `Are you sure you want to delete the environment group "${groupName}"? This action cannot be undone.`,
confirmButton: buildConfirmButton('Delete', 'danger'),
});
if (confirmed) {
deleteMutation.mutate(groupId, {
onSuccess() {
notifySuccess('Success', 'Environment group deleted');
router.stateService.go('portainer.groups');
},
});
}
}
const tabs: Array<Tab> = useMemo(
() => [
{
name: 'Environments',
icon: Box,
widget: (
<EnvironmentsTab
externalDrawerOpen={addEnvsDrawerOpen}
onExternalDrawerClose={() => setAddEnvsDrawerOpen(false)}
/>
),
selectedTabParam: 'environments',
},
],
[addEnvsDrawerOpen]
);
const currentTabIndex = useCurrentTabIndex(tabs);
return (
<>
<PageHeader
title="Environment group details"
breadcrumbs={[
{ label: 'Groups', link: 'portainer.groups' },
{ label: groupQuery.data?.Name ?? 'Edit group' },
]}
>
<DeleteButton
disabled={isUnassignedGroup || !groupQuery.data}
confirmMessage="Are you sure you want to delete this environment group? Environments within it will become unassigned."
onConfirmed={handleDelete}
isLoading={deleteMutation.isLoading}
data-cy="delete-environment-group-button"
title={groupName}
breadcrumbs={[{ label: 'Groups', link: 'portainer.groups' }, groupName]}
/>
<div className="mx-4 space-y-4">
<GroupHeader
group={group}
isLoading={groupQuery.isLoading}
onRefresh={() => groupQuery.refetch()}
onAddEnvironments={() => setAddEnvsDrawerOpen(true)}
onDelete={handleDeleteGroup}
/>
</PageHeader>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body loading={groupQuery.isLoading}>
{groupQuery.isError && (
<Alert color="error" title="Error">
Failed to load group details
</Alert>
)}
{!groupQuery.isError && groupQuery.data && (
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Update"
submitLoadingLabel="Updating..."
groupId={groupId}
/>
)}
</Widget.Body>
</Widget>
</div>
</div>
<div className="row pb-20">
<div className="col-sm-12">
<AssociatedEnvironmentsSelector
groupId={groupId}
readOnly={isUnassignedGroup}
/>
</div>
<WidgetTabs
tabs={tabs}
currentTabIndex={currentTabIndex}
useContainer={false}
/>
{tabs[currentTabIndex].widget}
</div>
</>
);
async function handleDelete() {
await deleteMutation.mutateAsync(groupId);
notifySuccess('Success', 'Environment group successfully deleted');
await router.stateService.go('portainer.groups');
queryClient.invalidateQueries({ queryKey: queryKeys.base(), exact: true });
}
async function handleSubmit(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
) {
await updateMutation.mutateAsync(
{
id: groupId,
name: values.name,
description: values.description,
tagIds: values.tagIds,
// associatedEnvironments omitted — backend preserves existing when field is absent (nil)
},
{
onSuccess() {
resetForm();
router.stateService.go('portainer.groups');
},
}
);
}
}
@@ -0,0 +1,88 @@
import { Layers, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useTags } from '@/portainer/tags/queries';
import { Button } from '@@/buttons';
import { ResourceDetailHeader } from '@@/ResourceDetailHeader/ResourceDetailHeader';
import { Badge } from '@@/Badge';
import { EnvironmentGroup } from '../types';
interface Props {
group?: EnvironmentGroup;
isLoading: boolean;
onRefresh?: () => void;
onAddEnvironments?: () => void;
onDelete?: () => void;
}
export function GroupHeader({
group,
isLoading,
onRefresh,
onAddEnvironments,
onDelete,
}: Props) {
const tagsQuery = useTags();
const tagBadges = group?.TagIds?.length
? group.TagIds.map((tagId) => {
const tag = tagsQuery.data?.find((t) => t.ID === tagId);
return (
<Badge key={tagId} type="info" className="text-xs">
{tag?.Name ?? `Tag ${tagId}`}
</Badge>
);
})
: undefined;
const actionBar = group ? (
<>
<div className="flex items-center gap-3">
<Button
color="none"
icon={RefreshCw}
onClick={() => onRefresh?.()}
data-cy="group-header-refresh"
>
Refresh
</Button>
<Button
color="none"
icon={Plus}
onClick={() => onAddEnvironments?.()}
data-cy="group-header-add-environments"
>
Add environments
</Button>
</div>
<div className="flex items-center gap-1">
<Button
color="none"
icon={Trash2}
onClick={() => onDelete?.()}
data-cy="group-header-delete"
>
Delete
</Button>
</div>
</>
) : undefined;
return (
<ResourceDetailHeader
isLoading={isLoading}
errorMessage={
!isLoading && !group ? 'Failed to load group details' : undefined
}
icon={<Layers className="text-blue-9 th-dark:text-blue-3" />}
iconBackgroundClassName="bg-blue-3 th-dark:bg-blue-9"
subtitleLabel="Environment Group"
subtitleClassName="text-blue-9 th-dark:text-blue-5"
title={group?.Name || ''}
badge={tagBadges ? <>{tagBadges}</> : undefined}
description={group?.Description}
actionBar={actionBar}
/>
);
}
@@ -0,0 +1,94 @@
import { FormikHelpers } from 'formik';
import { useMemo } from 'react';
import { useIdParam } from '@/react/hooks/useIdParam';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Widget } from '@@/Widget';
import { useGroup } from '../../queries/useGroup';
import { useUpdateGroupMutation } from '../../queries/useUpdateGroupMutation';
import { GroupForm, GroupFormValues } from '../../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
import { isUngoverned } from '../../utils/getPlatformLabel';
interface Props {
externalDrawerOpen?: boolean;
onExternalDrawerClose?: () => void;
}
export function EnvironmentsTab({
externalDrawerOpen,
onExternalDrawerClose,
}: Props) {
const groupId = useIdParam();
const groupQuery = useGroup(groupId);
const environmentsQuery = useEnvironmentList({
groupIds: [groupId],
pageLimit: 0,
});
const updateMutation = useUpdateGroupMutation();
const currentGroupIsUngoverned = groupQuery.data
? isUngoverned(groupQuery.data)
: false;
const initialValues: GroupFormValues = useMemo(
() => ({
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [],
}),
[groupQuery.data]
);
return (
<>
<Widget>
<Widget.Body loading={groupQuery.isLoading}>
{groupQuery.data && (
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Update"
submitLoadingLabel="Updating..."
groupId={groupId}
/>
)}
</Widget.Body>
</Widget>
<div className="pb-20">
<AssociatedEnvironmentsSelector
groupId={groupId}
readOnly={currentGroupIsUngoverned}
externalDrawerOpen={externalDrawerOpen}
onExternalDrawerClose={onExternalDrawerClose}
/>
</div>
</>
);
async function handleSubmit(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
) {
const associatedEnvironments = (environmentsQuery.environments ?? []).map(
(e) => e.Id
);
await updateMutation.mutateAsync(
{
id: groupId,
name: values.name,
description: values.description,
tagIds: values.tagIds,
associatedEnvironments,
},
{
onSuccess() {
resetForm();
},
}
);
}
}
@@ -17,11 +17,25 @@ interface Props {
groupId: EnvironmentGroupId;
/* For unassigned group, don't show the add/remove buttons and hide the checkbox */
readOnly: boolean;
externalDrawerOpen?: boolean;
onExternalDrawerClose?: () => void;
}
export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
export function AssociatedEnvironmentsSelector({
groupId,
readOnly,
externalDrawerOpen,
onExternalDrawerClose,
}: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const isDrawerOpen = drawerOpen || (externalDrawerOpen ?? false);
function closeDrawer() {
setDrawerOpen(false);
onExternalDrawerClose?.();
}
const groupQuery = useGroup(groupId);
const environmentsQuery = useEnvironmentList({
groupIds: [groupId],
@@ -46,8 +60,8 @@ export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
/>
<AddEnvironmentsDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
open={isDrawerOpen}
onClose={closeDrawer}
excludeGroupIds={[groupId]}
onAdd={handleAdd}
isLoading={updateMutation.isLoading}
@@ -46,60 +46,57 @@ export function AssociatedEnvironmentsTable({
const columns = useMemo(() => buildColumns(), []);
return (
// avoid padding issues with the widget
<div className="-mx-[15px]">
<Datatable<EnvironmentTableData>
disableSelect={readOnly}
isLoading={isLoading}
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
renderTableActions={(selectedItems) =>
readOnly ? null : (
<>
{confirmRemove ? (
<DeleteButton
disabled={selectedItems.length === 0}
isLoading={isRemoving}
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
onConfirmed={() => handleRemove(selectedItems)}
data-cy="remove-environments-button"
type="button"
/>
) : (
<DeleteButton
disabled={selectedItems.length === 0}
onClick={() => {
handleRemove(selectedItems);
}}
data-cy="remove-environments-button"
type="button"
/>
)}
<Button
icon={Plus}
onClick={onOpenAddDrawer}
data-cy="add-environments-button"
>
Add
</Button>
</>
)
}
data-cy={dataCy || 'environment-table'}
/>
</div>
<Datatable<EnvironmentTableData>
disableSelect={readOnly}
isLoading={isLoading}
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
renderTableActions={(selectedItems) =>
readOnly ? null : (
<>
{confirmRemove ? (
<DeleteButton
disabled={selectedItems.length === 0}
isLoading={isRemoving}
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
onConfirmed={() => handleRemove(selectedItems)}
data-cy="remove-environments-button"
type="button"
/>
) : (
<DeleteButton
disabled={selectedItems.length === 0}
onClick={() => {
handleRemove(selectedItems);
}}
data-cy="remove-environments-button"
type="button"
/>
)}
<Button
icon={Plus}
onClick={onOpenAddDrawer}
data-cy="add-environments-button"
>
Add
</Button>
</>
)
}
data-cy={dataCy || 'environment-table'}
/>
);
function handleRemove(selectedItems: EnvironmentTableData[]) {
@@ -1,6 +1,6 @@
import { EnvironmentGroupId } from '../../types';
export const queryKeys = {
base: () => ['environment-groups'] as const,
group: (id?: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
base: (size: boolean = false) => ['environment-groups', size] as const,
group: (id?: EnvironmentGroupId) => [...queryKeys.base(false), id] as const,
};
@@ -47,7 +47,7 @@ export function useCreateGroupMutation() {
createGroup,
mutationOptions(
withError('Failed to create group'),
withInvalidate(queryClient, [queryKeys.base()])
withInvalidate(queryClient, [queryKeys.base(), queryKeys.base(true)])
)
);
}
@@ -10,7 +10,7 @@ import { buildUrl } from './build-url';
export function useEnvironmentGroups() {
return useQuery({
queryKey: queryKeys.base(),
queryKey: queryKeys.base(true),
queryFn: () => getEnvironmentGroups(),
...withError('Unable to retrieve environment groups'),
});
@@ -18,7 +18,9 @@ export function useEnvironmentGroups() {
async function getEnvironmentGroups() {
try {
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl());
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl(), {
params: { size: true },
});
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve environment groups');
@@ -47,6 +47,7 @@ export function useUpdateGroupMutation() {
mutationFn: updateGroup,
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.base());
queryClient.invalidateQueries(queryKeys.base(true));
queryClient.invalidateQueries(environmentQueryKeys.base());
notifySuccess('Success', 'Group successfully updated');
},