mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:30:16 +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
|
portainer-checksum.txt
|
||||||
api/cmd/portainer/portainer*
|
api/cmd/portainer/portainer*
|
||||||
storybook-static
|
storybook-static
|
||||||
|
debug-storybook.log
|
||||||
.tmp
|
.tmp
|
||||||
**/.vscode/settings.json
|
**/.vscode/settings.json
|
||||||
**/.vscode/tasks.json
|
**/.vscode/tasks.json
|
||||||
|
|||||||
@@ -13,6 +13,50 @@ import (
|
|||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"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 {
|
type endpointGroupTypeInfo struct {
|
||||||
Docker int `json:"Docker"`
|
Docker int `json:"Docker"`
|
||||||
Kubernetes int `json:"Kubernetes"`
|
Kubernetes int `json:"Kubernetes"`
|
||||||
@@ -83,50 +127,11 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request
|
|||||||
if len(endpointGroups) == 0 {
|
if len(endpointGroups) == 0 {
|
||||||
return response.JSON(w, []portainer.EndpointGroup{})
|
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 endpointGroupCountMap map[portainer.EndpointGroupID]int
|
||||||
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo
|
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo
|
||||||
if includeSize {
|
if includeSize {
|
||||||
endpointGroupCountMap = make(map[portainer.EndpointGroupID]int)
|
endpointGroupCountMap, endpointGroupTypeInfoMap = computeGroupSizeInfo(endpointGroups, endpoints)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups))
|
endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups))
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func TestHandler_endpointGroupList(t *testing.T) {
|
|||||||
ID: 5,
|
ID: 5,
|
||||||
GroupID: groups[0].ID,
|
GroupID: groups[0].ID,
|
||||||
Type: portainer.DockerEnvironment,
|
Type: portainer.DockerEnvironment,
|
||||||
ContainerEngine: "podman",
|
ContainerEngine: portainer.ContainerEnginePodman,
|
||||||
}
|
}
|
||||||
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
|
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
|
||||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
|
||||||
|
|||||||
@@ -252,6 +252,9 @@ angular
|
|||||||
id: {
|
id: {
|
||||||
type: 'int',
|
type: 'int',
|
||||||
},
|
},
|
||||||
|
tab: {
|
||||||
|
dynamic: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export const ngModule = angular
|
|||||||
'onReload',
|
'onReload',
|
||||||
'reload',
|
'reload',
|
||||||
'id',
|
'id',
|
||||||
|
'showTitle',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.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 { PropsWithChildren, AnchorHTMLAttributes } from 'react';
|
||||||
import { UISrefProps, useSref } from '@uirouter/react';
|
import { UISrefProps, useSref } from '@uirouter/react';
|
||||||
|
|
||||||
interface Props {
|
interface Props extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
title?: string;
|
'data-cy': string;
|
||||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>['target'];
|
|
||||||
rel?: AnchorHTMLAttributes<HTMLAnchorElement>['rel'];
|
|
||||||
'data-cy': AnchorHTMLAttributes<HTMLAnchorElement>['data-cy'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link({
|
export function Link({
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ import { HeaderContainer } from './HeaderContainer';
|
|||||||
import { HeaderTitle } from './HeaderTitle';
|
import { HeaderTitle } from './HeaderTitle';
|
||||||
import { PageTitle } from './PageTitle';
|
import { PageTitle } from './PageTitle';
|
||||||
|
|
||||||
export type Breadcrumb = Crumb | string;
|
interface Props {
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
id?: string;
|
id?: string;
|
||||||
reload?: boolean;
|
reload?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onReload?(): Promise<void> | void;
|
onReload?(): Promise<void> | void;
|
||||||
breadcrumbs?: Breadcrumb[] | string;
|
breadcrumbs?: (Crumb | string)[] | string;
|
||||||
title?: 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({
|
export function PageHeader({
|
||||||
@@ -30,6 +32,7 @@ export function PageHeader({
|
|||||||
reload,
|
reload,
|
||||||
loading,
|
loading,
|
||||||
onReload,
|
onReload,
|
||||||
|
showTitle = !!title,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -41,22 +44,26 @@ export function PageHeader({
|
|||||||
<HeaderTitle />
|
<HeaderTitle />
|
||||||
</HeaderContainer>
|
</HeaderContainer>
|
||||||
|
|
||||||
{title && (
|
{showTitle && title && (
|
||||||
<PageTitle title={title}>
|
<PageTitle title={title}>
|
||||||
{reload && (
|
{(reload || children) && (
|
||||||
<Button
|
<div className="ml-auto flex items-center gap-2">
|
||||||
color="none"
|
{reload && (
|
||||||
size="large"
|
<Button
|
||||||
onClick={onClickedRefresh}
|
color="none"
|
||||||
className="m-0 p-0 focus:text-inherit"
|
size="large"
|
||||||
disabled={loading}
|
onClick={onClickedRefresh}
|
||||||
title="Refresh page"
|
className="m-0 p-0 focus:text-inherit"
|
||||||
data-cy="refresh-page-button"
|
disabled={loading}
|
||||||
>
|
title="Refresh page"
|
||||||
<RefreshCw className="icon" />
|
data-cy="refresh-page-button"
|
||||||
</Button>
|
>
|
||||||
|
<RefreshCw className="icon" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@@ -16,6 +17,9 @@ interface Props {
|
|||||||
rightInfo?: ReactNode;
|
rightInfo?: ReactNode;
|
||||||
actionBar?: ReactNode;
|
actionBar?: ReactNode;
|
||||||
|
|
||||||
|
isLoading?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
widgetClassName?: string;
|
widgetClassName?: string;
|
||||||
}
|
}
|
||||||
@@ -30,27 +34,38 @@ export function ResourceDetailHeader({
|
|||||||
description,
|
description,
|
||||||
rightInfo,
|
rightInfo,
|
||||||
actionBar,
|
actionBar,
|
||||||
|
isLoading,
|
||||||
|
errorMessage,
|
||||||
containerClassName = 'flex items-center gap-4 p-6',
|
containerClassName = 'flex items-center gap-4 p-6',
|
||||||
widgetClassName = 'widget-body',
|
widgetClassName = 'widget-body',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Widget className={widgetClassName}>
|
<Widget className={widgetClassName}>
|
||||||
<div className={containerClassName}>
|
<Widget.Body loading={isLoading}>
|
||||||
<HeaderIcon
|
{errorMessage && (
|
||||||
icon={icon}
|
<Alert color="error" title="Error">
|
||||||
iconBackgroundClassName={iconBackgroundClassName}
|
{errorMessage}
|
||||||
/>
|
</Alert>
|
||||||
<HeaderInfo
|
)}
|
||||||
subtitleLabel={subtitleLabel}
|
{!errorMessage && (
|
||||||
subtitleClassName={subtitleClassName}
|
<div className={containerClassName}>
|
||||||
title={title}
|
<HeaderIcon
|
||||||
badge={badge}
|
icon={icon}
|
||||||
description={description}
|
iconBackgroundClassName={iconBackgroundClassName}
|
||||||
/>
|
/>
|
||||||
{rightInfo}
|
<HeaderInfo
|
||||||
</div>
|
subtitleLabel={subtitleLabel}
|
||||||
|
subtitleClassName={subtitleClassName}
|
||||||
|
title={title}
|
||||||
|
badge={badge}
|
||||||
|
description={description}
|
||||||
|
/>
|
||||||
|
{rightInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Widget.Body>
|
||||||
|
|
||||||
{actionBar}
|
{!isLoading && !errorMessage && actionBar}
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,10 @@ export function TableContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<div className="datatable">
|
||||||
<div className="col-sm-12">
|
<Widget aria-label={ariaLabel} id={id}>
|
||||||
<div className="datatable">
|
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||||
<Widget aria-label={ariaLabel} id={id}>
|
</Widget>
|
||||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
|
||||||
</Widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function RegistryBadge({ registryId, children, dataCy }: Props) {
|
|||||||
to="portainer.registries.registry"
|
to="portainer.registries.registry"
|
||||||
params={{ id: registryId }}
|
params={{ id: registryId }}
|
||||||
className="!text-inherit"
|
className="!text-inherit"
|
||||||
data-cy={dataCy ? `${dataCy}-link` : undefined}
|
data-cy={dataCy ? `${dataCy}-link` : 'registry-badge-link'}
|
||||||
>
|
>
|
||||||
{Name}
|
{Name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+131
-10
@@ -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 userEvent from '@testing-library/user-event';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { http, HttpResponse, DefaultBodyType } from 'msw';
|
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 { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
import { CreateGroupView } from './CreateGroupView';
|
import { CreateGroupView } from './CreateGroupView';
|
||||||
|
|
||||||
@@ -21,7 +22,11 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||||||
|
|
||||||
function renderCreateGroupView({
|
function renderCreateGroupView({
|
||||||
onMutationError,
|
onMutationError,
|
||||||
}: { onMutationError?(): void } = {}) {
|
availableEnvironments = [] as ReturnType<typeof createMockEnvironment>[],
|
||||||
|
}: {
|
||||||
|
onMutationError?(): void;
|
||||||
|
availableEnvironments?: ReturnType<typeof createMockEnvironment>[];
|
||||||
|
} = {}) {
|
||||||
// Set up default mocks
|
// Set up default mocks
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/tags', () =>
|
http.get('/api/tags', () =>
|
||||||
@@ -30,14 +35,24 @@ function renderCreateGroupView({
|
|||||||
{ ID: 2, Name: 'staging' },
|
{ ID: 2, Name: 'staging' },
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
http.get('/api/endpoints', () =>
|
http.get('/api/endpoints', ({ request }) => {
|
||||||
HttpResponse.json([], {
|
const url = new URL(request.url);
|
||||||
headers: {
|
const groupIds = [
|
||||||
'x-total-count': '0',
|
...url.searchParams.getAll('groupIds'),
|
||||||
'x-total-available': '0',
|
...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(
|
const Wrapped = withTestQueryProvider(
|
||||||
@@ -263,4 +278,110 @@ describe('CreateGroupView', () => {
|
|||||||
expect(submitButton).toBeDisabled();
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-13
@@ -30,19 +30,17 @@ export function CreateGroupView() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row pb-20">
|
<div className="mx-4 pb-20">
|
||||||
<div className="col-sm-12">
|
<Widget>
|
||||||
<Widget>
|
<Widget.Body>
|
||||||
<Widget.Body>
|
<GroupForm
|
||||||
<GroupForm
|
initialValues={initialValues}
|
||||||
initialValues={initialValues}
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
submitLabel="Create"
|
||||||
submitLabel="Create"
|
submitLoadingLabel="Creating..."
|
||||||
submitLoadingLabel="Creating..."
|
/>
|
||||||
/>
|
</Widget.Body>
|
||||||
</Widget.Body>
|
</Widget>
|
||||||
</Widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+77
-42
@@ -31,6 +31,28 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||||||
notifySuccess: vi.fn(),
|
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 = {
|
const mockGroup: EnvironmentGroup = {
|
||||||
Id: 2,
|
Id: 2,
|
||||||
Name: 'Test Group',
|
Name: 'Test Group',
|
||||||
@@ -153,9 +175,8 @@ describe('EditGroupView', () => {
|
|||||||
it('should render the page header with correct title', async () => {
|
it('should render the page header with correct title', async () => {
|
||||||
renderEditGroupView();
|
renderEditGroupView();
|
||||||
|
|
||||||
expect(
|
// The component should render without errors and breadcrumbs should be visible
|
||||||
await screen.findByText('Environment group details')
|
expect(await screen.findByText('Groups')).toBeVisible();
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render breadcrumbs with link to Groups', async () => {
|
it('should render breadcrumbs with link to Groups', async () => {
|
||||||
@@ -164,10 +185,17 @@ describe('EditGroupView', () => {
|
|||||||
expect(await screen.findByText('Groups')).toBeVisible();
|
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();
|
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 () => {
|
it('should render the Update button', async () => {
|
||||||
@@ -215,16 +243,14 @@ describe('EditGroupView', () => {
|
|||||||
expect(descriptionInput).toHaveValue('Test description');
|
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();
|
renderEditGroupView();
|
||||||
|
|
||||||
// Wait for form to load
|
// Wait for form to load
|
||||||
await screen.findByLabelText(/Name/i);
|
await screen.findByLabelText(/Name/i);
|
||||||
|
|
||||||
// Check that at least one "Associated environments" text exists (section + table title)
|
// The Environments tab should be visible
|
||||||
const elements = screen.getAllByText(/Associated environments/i);
|
expect(screen.getByText('Environments')).toBeVisible();
|
||||||
expect(elements.length).toBeGreaterThan(0);
|
|
||||||
expect(elements[0]).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,16 +268,6 @@ describe('EditGroupView', () => {
|
|||||||
).toBeVisible();
|
).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 () => {
|
it('should NOT show the form when group fetch fails', async () => {
|
||||||
renderEditGroupView({ groupData: null });
|
renderEditGroupView({ groupData: null });
|
||||||
|
|
||||||
@@ -306,7 +322,7 @@ describe('EditGroupView', () => {
|
|||||||
// Verify the request URL and body.
|
// Verify the request URL and body.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestUrl).toBe('/api/endpoint_groups/2');
|
expect(requestUrl).toBe('/api/endpoint_groups/2');
|
||||||
expect(requestBody).toEqual({
|
expect(requestBody).toMatchObject({
|
||||||
Name: 'Updated Group',
|
Name: 'Updated Group',
|
||||||
Description: 'Test description',
|
Description: 'Test description',
|
||||||
TagIDs: [1],
|
TagIDs: [1],
|
||||||
@@ -435,25 +451,8 @@ describe('EditGroupView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Associated environments', () => {
|
describe('Update payload', () => {
|
||||||
it('should display initially associated environments', async () => {
|
it('should include AssociatedEndpoints in update payload', 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 () => {
|
|
||||||
let requestBody: DefaultBodyType;
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
@@ -482,10 +481,46 @@ describe('EditGroupView', () => {
|
|||||||
|
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
|
|
||||||
// Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
|
// Verify AssociatedEndpoints is present in the payload
|
||||||
await waitFor(() => {
|
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 { 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 { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
import { Alert } from '@@/Alert';
|
import { Tab, WidgetTabs, useCurrentTabIndex } from '@@/Widget/WidgetTabs';
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
import { confirm } from '@@/modals/confirm';
|
||||||
|
import { ModalType } from '@@/modals/Modal';
|
||||||
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
|
||||||
import { useGroup } from '../queries/useGroup';
|
import { useGroup } from '../queries/useGroup';
|
||||||
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
|
||||||
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
|
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
|
||||||
import { queryKeys } from '../queries/query-keys';
|
|
||||||
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
import { EnvironmentsTab } from './tabs/EnvironmentsTab';
|
||||||
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
import { GroupHeader } from './GroupHeader';
|
||||||
|
|
||||||
export function EditGroupView() {
|
export function EditGroupView() {
|
||||||
const groupId = useIdParam();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const groupId = useIdParam();
|
||||||
const groupQuery = useGroup(groupId);
|
|
||||||
const isUnassignedGroup = groupId === 1;
|
|
||||||
const updateMutation = useUpdateGroupMutation();
|
|
||||||
const deleteMutation = useDeleteEnvironmentGroupMutation();
|
const deleteMutation = useDeleteEnvironmentGroupMutation();
|
||||||
|
const [addEnvsDrawerOpen, setAddEnvsDrawerOpen] = useState(false);
|
||||||
const initialValues: GroupFormValues = useMemo(
|
const groupQuery = useGroup(
|
||||||
() => ({
|
deleteMutation.isLoading || deleteMutation.isSuccess ? undefined : groupId
|
||||||
name: groupQuery.data?.Name ?? '',
|
|
||||||
description: groupQuery.data?.Description ?? '',
|
|
||||||
tagIds: groupQuery.data?.TagIds ?? [],
|
|
||||||
}),
|
|
||||||
[groupQuery.data]
|
|
||||||
);
|
);
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Environment group details"
|
title={groupName}
|
||||||
breadcrumbs={[
|
breadcrumbs={[{ label: 'Groups', link: 'portainer.groups' }, groupName]}
|
||||||
{ label: 'Groups', link: 'portainer.groups' },
|
/>
|
||||||
{ label: groupQuery.data?.Name ?? 'Edit group' },
|
<div className="mx-4 space-y-4">
|
||||||
]}
|
<GroupHeader
|
||||||
>
|
group={group}
|
||||||
<DeleteButton
|
isLoading={groupQuery.isLoading}
|
||||||
disabled={isUnassignedGroup || !groupQuery.data}
|
onRefresh={() => groupQuery.refetch()}
|
||||||
confirmMessage="Are you sure you want to delete this environment group? Environments within it will become unassigned."
|
onAddEnvironments={() => setAddEnvsDrawerOpen(true)}
|
||||||
onConfirmed={handleDelete}
|
onDelete={handleDeleteGroup}
|
||||||
isLoading={deleteMutation.isLoading}
|
|
||||||
data-cy="delete-environment-group-button"
|
|
||||||
/>
|
/>
|
||||||
</PageHeader>
|
<WidgetTabs
|
||||||
|
tabs={tabs}
|
||||||
<div className="row">
|
currentTabIndex={currentTabIndex}
|
||||||
<div className="col-sm-12">
|
useContainer={false}
|
||||||
<Widget>
|
/>
|
||||||
<Widget.Body loading={groupQuery.isLoading}>
|
{tabs[currentTabIndex].widget}
|
||||||
{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>
|
|
||||||
</div>
|
</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;
|
groupId: EnvironmentGroupId;
|
||||||
/* For unassigned group, don't show the add/remove buttons and hide the checkbox */
|
/* For unassigned group, don't show the add/remove buttons and hide the checkbox */
|
||||||
readOnly: boolean;
|
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 [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const isDrawerOpen = drawerOpen || (externalDrawerOpen ?? false);
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
onExternalDrawerClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
const groupQuery = useGroup(groupId);
|
const groupQuery = useGroup(groupId);
|
||||||
const environmentsQuery = useEnvironmentList({
|
const environmentsQuery = useEnvironmentList({
|
||||||
groupIds: [groupId],
|
groupIds: [groupId],
|
||||||
@@ -46,8 +60,8 @@ export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AddEnvironmentsDrawer
|
<AddEnvironmentsDrawer
|
||||||
open={drawerOpen}
|
open={isDrawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={closeDrawer}
|
||||||
excludeGroupIds={[groupId]}
|
excludeGroupIds={[groupId]}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
isLoading={updateMutation.isLoading}
|
isLoading={updateMutation.isLoading}
|
||||||
|
|||||||
+51
-54
@@ -46,60 +46,57 @@ export function AssociatedEnvironmentsTable({
|
|||||||
const columns = useMemo(() => buildColumns(), []);
|
const columns = useMemo(() => buildColumns(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// avoid padding issues with the widget
|
<Datatable<EnvironmentTableData>
|
||||||
<div className="-mx-[15px]">
|
disableSelect={readOnly}
|
||||||
<Datatable<EnvironmentTableData>
|
isLoading={isLoading}
|
||||||
disableSelect={readOnly}
|
title={title}
|
||||||
isLoading={isLoading}
|
columns={columns}
|
||||||
title={title}
|
settingsManager={tableState}
|
||||||
columns={columns}
|
dataset={environments}
|
||||||
settingsManager={tableState}
|
getRowId={(row) => String(row.Id)}
|
||||||
dataset={environments}
|
renderRow={(row) => (
|
||||||
getRowId={(row) => String(row.Id)}
|
<TableRow<EnvironmentTableData>
|
||||||
renderRow={(row) => (
|
cells={row.getVisibleCells()}
|
||||||
<TableRow<EnvironmentTableData>
|
onClick={() => row.toggleSelected()}
|
||||||
cells={row.getVisibleCells()}
|
className={clsx({ active: row.getIsSelected() })}
|
||||||
onClick={() => row.toggleSelected()}
|
aria-selected={row.getIsSelected()}
|
||||||
className={clsx({ active: row.getIsSelected() })}
|
/>
|
||||||
aria-selected={row.getIsSelected()}
|
)}
|
||||||
/>
|
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
|
||||||
)}
|
renderTableActions={(selectedItems) =>
|
||||||
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
|
readOnly ? null : (
|
||||||
renderTableActions={(selectedItems) =>
|
<>
|
||||||
readOnly ? null : (
|
{confirmRemove ? (
|
||||||
<>
|
<DeleteButton
|
||||||
{confirmRemove ? (
|
disabled={selectedItems.length === 0}
|
||||||
<DeleteButton
|
isLoading={isRemoving}
|
||||||
disabled={selectedItems.length === 0}
|
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
|
||||||
isLoading={isRemoving}
|
onConfirmed={() => handleRemove(selectedItems)}
|
||||||
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
|
data-cy="remove-environments-button"
|
||||||
onConfirmed={() => handleRemove(selectedItems)}
|
type="button"
|
||||||
data-cy="remove-environments-button"
|
/>
|
||||||
type="button"
|
) : (
|
||||||
/>
|
<DeleteButton
|
||||||
) : (
|
disabled={selectedItems.length === 0}
|
||||||
<DeleteButton
|
onClick={() => {
|
||||||
disabled={selectedItems.length === 0}
|
handleRemove(selectedItems);
|
||||||
onClick={() => {
|
}}
|
||||||
handleRemove(selectedItems);
|
data-cy="remove-environments-button"
|
||||||
}}
|
type="button"
|
||||||
data-cy="remove-environments-button"
|
/>
|
||||||
type="button"
|
)}
|
||||||
/>
|
<Button
|
||||||
)}
|
icon={Plus}
|
||||||
<Button
|
onClick={onOpenAddDrawer}
|
||||||
icon={Plus}
|
data-cy="add-environments-button"
|
||||||
onClick={onOpenAddDrawer}
|
>
|
||||||
data-cy="add-environments-button"
|
Add
|
||||||
>
|
</Button>
|
||||||
Add
|
</>
|
||||||
</Button>
|
)
|
||||||
</>
|
}
|
||||||
)
|
data-cy={dataCy || 'environment-table'}
|
||||||
}
|
/>
|
||||||
data-cy={dataCy || 'environment-table'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleRemove(selectedItems: EnvironmentTableData[]) {
|
function handleRemove(selectedItems: EnvironmentTableData[]) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EnvironmentGroupId } from '../../types';
|
import { EnvironmentGroupId } from '../../types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
base: () => ['environment-groups'] as const,
|
base: (size: boolean = false) => ['environment-groups', size] as const,
|
||||||
group: (id?: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
|
group: (id?: EnvironmentGroupId) => [...queryKeys.base(false), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ export function useCreateGroupMutation() {
|
|||||||
createGroup,
|
createGroup,
|
||||||
mutationOptions(
|
mutationOptions(
|
||||||
withError('Failed to create group'),
|
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() {
|
export function useEnvironmentGroups() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.base(),
|
queryKey: queryKeys.base(true),
|
||||||
queryFn: () => getEnvironmentGroups(),
|
queryFn: () => getEnvironmentGroups(),
|
||||||
...withError('Unable to retrieve environment groups'),
|
...withError('Unable to retrieve environment groups'),
|
||||||
});
|
});
|
||||||
@@ -18,7 +18,9 @@ export function useEnvironmentGroups() {
|
|||||||
|
|
||||||
async function getEnvironmentGroups() {
|
async function getEnvironmentGroups() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl());
|
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl(), {
|
||||||
|
params: { size: true },
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e, 'Unable to retrieve environment groups');
|
throw parseAxiosError(e, 'Unable to retrieve environment groups');
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function useUpdateGroupMutation() {
|
|||||||
mutationFn: updateGroup,
|
mutationFn: updateGroup,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(queryKeys.base());
|
queryClient.invalidateQueries(queryKeys.base());
|
||||||
|
queryClient.invalidateQueries(queryKeys.base(true));
|
||||||
queryClient.invalidateQueries(environmentQueryKeys.base());
|
queryClient.invalidateQueries(environmentQueryKeys.base());
|
||||||
notifySuccess('Success', 'Group successfully updated');
|
notifySuccess('Success', 'Group successfully updated');
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user