mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:40:13 +00:00
feat(environment group) detail view update v1 [c9s-206] (#2722)
Last system-test failure is also on dev
This commit is contained in:
@@ -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) })
|
||||
|
||||
@@ -252,6 +252,9 @@ angular
|
||||
id: {
|
||||
type: 'int',
|
||||
},
|
||||
tab: {
|
||||
dynamic: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ export const ngModule = angular
|
||||
'onReload',
|
||||
'reload',
|
||||
'id',
|
||||
'showTitle',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,8 +44,10 @@ export function PageHeader({
|
||||
<HeaderTitle />
|
||||
</HeaderContainer>
|
||||
|
||||
{title && (
|
||||
{showTitle && title && (
|
||||
<PageTitle title={title}>
|
||||
{(reload || children) && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{reload && (
|
||||
<Button
|
||||
color="none"
|
||||
@@ -57,6 +62,8 @@ export function PageHeader({
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</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,11 +34,20 @@ export function ResourceDetailHeader({
|
||||
description,
|
||||
rightInfo,
|
||||
actionBar,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
containerClassName = 'flex items-center gap-4 p-6',
|
||||
widgetClassName = 'widget-body',
|
||||
}: Props) {
|
||||
return (
|
||||
<Widget className={widgetClassName}>
|
||||
<Widget.Body loading={isLoading}>
|
||||
{errorMessage && (
|
||||
<Alert color="error" title="Error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{!errorMessage && (
|
||||
<div className={containerClassName}>
|
||||
<HeaderIcon
|
||||
icon={icon}
|
||||
@@ -49,8 +62,10 @@ export function ResourceDetailHeader({
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+128
-7
@@ -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([], {
|
||||
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': '0',
|
||||
'x-total-available': '0',
|
||||
'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,8 +30,7 @@ export function CreateGroupView() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row pb-20">
|
||||
<div className="col-sm-12">
|
||||
<div className="mx-4 pb-20">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<GroupForm
|
||||
@@ -43,7 +42,6 @@ export function CreateGroupView() {
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
+77
-42
@@ -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]}
|
||||
/>
|
||||
</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}
|
||||
<div className="mx-4 space-y-4">
|
||||
<GroupHeader
|
||||
group={group}
|
||||
isLoading={groupQuery.isLoading}
|
||||
onRefresh={() => groupQuery.refetch()}
|
||||
onAddEnvironments={() => setAddEnvsDrawerOpen(true)}
|
||||
onDelete={handleDeleteGroup}
|
||||
/>
|
||||
)}
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row pb-20">
|
||||
<div className="col-sm-12">
|
||||
<AssociatedEnvironmentsSelector
|
||||
groupId={groupId}
|
||||
readOnly={isUnassignedGroup}
|
||||
<WidgetTabs
|
||||
tabs={tabs}
|
||||
currentTabIndex={currentTabIndex}
|
||||
useContainer={false}
|
||||
/>
|
||||
</div>
|
||||
{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
-3
@@ -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}
|
||||
|
||||
-3
@@ -46,8 +46,6 @@ export function AssociatedEnvironmentsTable({
|
||||
const columns = useMemo(() => buildColumns(), []);
|
||||
|
||||
return (
|
||||
// avoid padding issues with the widget
|
||||
<div className="-mx-[15px]">
|
||||
<Datatable<EnvironmentTableData>
|
||||
disableSelect={readOnly}
|
||||
isLoading={isLoading}
|
||||
@@ -99,7 +97,6 @@ export function AssociatedEnvironmentsTable({
|
||||
}
|
||||
data-cy={dataCy || 'environment-table'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
+1
-1
@@ -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');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user