From 484af3c2c8c2e9fb2fec499cd25b306d605aaad1 Mon Sep 17 00:00:00 2001 From: Josiah Clumont Date: Tue, 2 Jun 2026 16:59:18 +1200 Subject: [PATCH] feat(environment group) detail view update v1 [c9s-206] (#2722) Last system-test failure is also on dev --- .gitignore | 1 + .../endpointgroups/endpointgroup_list.go | 85 +++---- .../endpointgroups/endpointgroup_list_test.go | 2 +- app/portainer/__module.js | 3 + app/portainer/react/components/index.ts | 1 + app/portainer/services/axios/index.ts | 3 + .../GroupSortTableHeader.stories.tsx | 166 ++++++++++++++ .../GroupSortTableHeader.test.tsx | 210 ++++++++++++++++++ .../GroupSortTable/GroupSortTableHeader.tsx | 165 ++++++++++++++ app/react/components/Link.tsx | 7 +- .../components/PageHeader/PageHeader.tsx | 43 ++-- .../ResourceDetailHeader.tsx | 45 ++-- .../components/datatables/TableContainer.tsx | 12 +- .../configs/secrets/RegistryBadge.tsx | 2 +- .../CreateView/CreateGroupView.test.tsx | 141 +++++++++++- .../CreateView/CreateGroupView.tsx | 24 +- .../ItemView/EditGroupView.test.tsx | 119 ++++++---- .../ItemView/EditGroupView.tsx | 165 ++++++-------- .../ItemView/GroupHeader.tsx | 88 ++++++++ .../ItemView/tabs/EnvironmentsTab.tsx | 94 ++++++++ .../AssociatedEnvironmentsSelector.tsx | 20 +- .../AssociatedEnvironmentsTable.tsx | 105 +++++---- .../environment-groups/queries/query-keys.ts | 4 +- .../queries/useCreateGroupMutation.ts | 2 +- .../queries/useEnvironmentGroups.ts | 6 +- .../queries/useUpdateGroupMutation.ts | 1 + 26 files changed, 1203 insertions(+), 311 deletions(-) create mode 100644 app/portainer/services/axios/index.ts create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.tsx create mode 100644 app/react/portainer/environments/environment-groups/ItemView/GroupHeader.tsx create mode 100644 app/react/portainer/environments/environment-groups/ItemView/tabs/EnvironmentsTab.tsx diff --git a/.gitignore b/.gitignore index 1634facb1b..42cd529d68 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist portainer-checksum.txt api/cmd/portainer/portainer* storybook-static +debug-storybook.log .tmp **/.vscode/settings.json **/.vscode/tasks.json diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go index e733b4b2d8..8b6f5060be 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list.go +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -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)) diff --git a/api/http/handler/endpointgroups/endpointgroup_list_test.go b/api/http/handler/endpointgroups/endpointgroup_list_test.go index 98599c9b78..5da4e52645 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list_test.go +++ b/api/http/handler/endpointgroups/endpointgroup_list_test.go @@ -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) }) diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 0be24ae5c4..6dbb99ac52 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -252,6 +252,9 @@ angular id: { type: 'int', }, + tab: { + dynamic: true, + }, }, }; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index ed08e4ac33..d7fcd1c4e2 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -126,6 +126,7 @@ export const ngModule = angular 'onReload', 'reload', 'id', + 'showTitle', ]) ) .component( diff --git a/app/portainer/services/axios/index.ts b/app/portainer/services/axios/index.ts new file mode 100644 index 0000000000..8c902d8e43 --- /dev/null +++ b/app/portainer/services/axios/index.ts @@ -0,0 +1,3 @@ +export * from './axios'; +// eslint-disable-next-line no-restricted-exports +export { default } from './axios'; diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx new file mode 100644 index 0000000000..4197899262 --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx @@ -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: }, + { key: 'Staging', count: 5, icon: }, + { key: 'Development', count: 8, icon: }, +]; + +export function Interactive() { + const [sortBy, setSortBy] = useState('name'); + const [searchTerm, setSearchTerm] = useState(''); + const [groupFilter, setGroupFilter] = useState(null); + + const sortOptions: SortOption[] = [ + { key: 'name', label: 'Name' }, + { + key: 'group', + label: 'Group', + dropdown: { + options: availableGroups, + selected: groupFilter, + onSelect: setGroupFilter, + }, + }, + { key: 'status', label: 'Status' }, + ]; + + return ( + + ); +} + +export function MixedButtonTypes() { + const [sortBy, setSortBy] = useState('Name'); + const [searchTerm, setSearchTerm] = useState(''); + const [platformFilter, setPlatformFilter] = useState(null); + const [govFilter, setGovFilter] = useState(null); + + const sortOptions: SortOption[] = [ + { 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 ( +
+ + Add Group + + } + /> +
+ Sort: {sortBy} | Platform:{' '} + {platformFilter ?? 'All'} | Governance:{' '} + {govFilter ?? 'All'} +
+
+ ); +} + +export function MultipleBadgesPersist() { + const [sortBy, setSortBy] = useState('Governance'); + const [searchTerm, setSearchTerm] = useState(''); + + const sortOptions: SortOption[] = [ + { 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 ( + + ); +} + +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'); +}; diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx new file mode 100644 index 0000000000..aef591e1d0 --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx @@ -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[] = [ + { 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> + > = {} +) { + const props = { + sortBy: 'Name' as string, + onSortChange: vi.fn(), + searchTerm: '', + onSearchChange: vi.fn(), + sortOptions: defaultSortOptions, + ...overrides, + }; + + return { + ...render(), + 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[] = [ + { + 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: , + }); + + 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); + }); +}); diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx new file mode 100644 index 0000000000..c332c95264 --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx @@ -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 { + key: TSortKey; + label: string; + dropdown?: { + options: DropdownOption[]; + selected: string | null; + onSelect: (value: string | null) => void; + }; +} + +interface Props { + sortBy: TSortKey; + sortAsc?: boolean; + onSortChange: (key: TSortKey) => void; + onClear?: () => void; + searchTerm: string; + onSearchChange: (term: string) => void; + sortOptions: SortOption[]; + 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({ + sortBy, + sortAsc, + onSortChange, + onClear, + searchTerm, + onSearchChange, + sortOptions, + searchPlaceholder = 'Filter...', + actionButton, + searchDataCy, +}: Props) { + return ( +
+ + SORT BY: + +
+ {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 ( + + ); + } + + return ( + + ); + })} +
+ {onClear && ( + + + + )} +
+ + {actionButton} +
+
+ ); +} diff --git a/app/react/components/Link.tsx b/app/react/components/Link.tsx index ecef5c4087..e1348bb880 100644 --- a/app/react/components/Link.tsx +++ b/app/react/components/Link.tsx @@ -1,11 +1,8 @@ import { PropsWithChildren, AnchorHTMLAttributes } from 'react'; import { UISrefProps, useSref } from '@uirouter/react'; -interface Props { - title?: string; - target?: AnchorHTMLAttributes['target']; - rel?: AnchorHTMLAttributes['rel']; - 'data-cy': AnchorHTMLAttributes['data-cy']; +interface Props extends AnchorHTMLAttributes { + 'data-cy': string; } export function Link({ diff --git a/app/react/components/PageHeader/PageHeader.tsx b/app/react/components/PageHeader/PageHeader.tsx index 7580254e6b..74ae4dc26e 100644 --- a/app/react/components/PageHeader/PageHeader.tsx +++ b/app/react/components/PageHeader/PageHeader.tsx @@ -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; - 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) { const router = useRouter(); @@ -41,22 +44,26 @@ export function PageHeader({ - {title && ( + {showTitle && title && ( - {reload && ( - + {(reload || children) && ( +
+ {reload && ( + + )} + {children} +
)} - {children}
)} diff --git a/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx b/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx index 84cfc30e9e..f3f18bbd9e 100644 --- a/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx +++ b/app/react/components/ResourceDetailHeader/ResourceDetailHeader.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; import { Widget } from '@@/Widget'; +import { Alert } from '@@/Alert'; interface Props { icon: ReactNode; @@ -16,6 +17,9 @@ interface Props { rightInfo?: ReactNode; actionBar?: ReactNode; + isLoading?: boolean; + errorMessage?: string; + containerClassName?: string; widgetClassName?: string; } @@ -30,27 +34,38 @@ export function ResourceDetailHeader({ description, rightInfo, actionBar, + isLoading, + errorMessage, containerClassName = 'flex items-center gap-4 p-6', widgetClassName = 'widget-body', }: Props) { return ( -
- - - {rightInfo} -
+ + {errorMessage && ( + + {errorMessage} + + )} + {!errorMessage && ( +
+ + + {rightInfo} +
+ )} +
- {actionBar} + {!isLoading && !errorMessage && actionBar}
); } diff --git a/app/react/components/datatables/TableContainer.tsx b/app/react/components/datatables/TableContainer.tsx index 537bd6f39e..29da27734a 100644 --- a/app/react/components/datatables/TableContainer.tsx +++ b/app/react/components/datatables/TableContainer.tsx @@ -24,14 +24,10 @@ export function TableContainer({ } return ( -
-
-
- - {children} - -
-
+
+ + {children} +
); } diff --git a/app/react/kubernetes/configs/secrets/RegistryBadge.tsx b/app/react/kubernetes/configs/secrets/RegistryBadge.tsx index 0a2f1db970..b73ffd28ef 100644 --- a/app/react/kubernetes/configs/secrets/RegistryBadge.tsx +++ b/app/react/kubernetes/configs/secrets/RegistryBadge.tsx @@ -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} diff --git a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx index 66d25705d2..4c5760bf66 100644 --- a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx @@ -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[], +}: { + onMutationError?(): void; + availableEnvironments?: ReturnType[]; +} = {}) { // Set up default mocks server.use( http.get('/api/tags', () => @@ -30,14 +35,24 @@ function renderCreateGroupView({ { ID: 2, Name: 'staging' }, ]) ), - http.get('/api/endpoints', () => - HttpResponse.json([], { - headers: { - 'x-total-count': '0', - 'x-total-available': '0', - }, - }) - ) + http.get('/api/endpoints', ({ request }) => { + const url = new URL(request.url); + const groupIds = [ + ...url.searchParams.getAll('groupIds'), + ...url.searchParams.getAll('groupIds[]'), + ]; + if (groupIds.includes('1') && availableEnvironments.length > 0) { + return HttpResponse.json(availableEnvironments, { + headers: { + 'x-total-count': String(availableEnvironments.length), + 'x-total-available': String(availableEnvironments.length), + }, + }); + } + return HttpResponse.json([], { + headers: { 'x-total-count': '0', 'x-total-available': '0' }, + }); + }) ); const Wrapped = withTestQueryProvider( @@ -263,4 +278,110 @@ describe('CreateGroupView', () => { expect(submitButton).toBeDisabled(); }); }); + + describe('Environment selection via drawer', () => { + it('should include environments selected in the drawer in the POST payload', async () => { + let requestBody: DefaultBodyType; + const env = createMockEnvironment({ Id: 42, Name: 'my-test-env' }); + + server.use( + http.post('/api/endpoint_groups', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ + Id: 1, + Name: 'test-group', + TagIds: [], + Policies: [], + }); + }) + ); + + const user = userEvent.setup(); + renderCreateGroupView({ availableEnvironments: [env] }); + + const nameInput = await screen.findByLabelText(/Name/i); + await user.type(nameInput, 'test-group'); + + // Open the add environments drawer + const addBtn = await screen.findByTestId('add-environments-button'); + await user.click(addBtn); + + // Wait for the environment to appear in the drawer, then select via checkbox + await screen.findByText('my-test-env'); + const drawerTable = screen.getByTestId('add-environments-drawer-table'); + const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox'); + await user.click(drawerCheckboxes[1]); // [0] = select-all header, [1] = first row + + // Confirm the selection + const confirmBtn = screen.getByTestId('add-environments-confirm-button'); + await waitFor(() => expect(confirmBtn).toBeEnabled()); + await user.click(confirmBtn); + + // Submit the form + const submitBtn = screen.getByRole('button', { name: /Create/i }); + await waitFor(() => expect(submitBtn).toBeEnabled()); + await user.click(submitBtn); + + await waitFor(() => { + expect(requestBody).toMatchObject({ + Name: 'test-group', + AssociatedEndpoints: [42], + }); + }); + }); + + it('should allow removing a selected environment before submitting', async () => { + let requestBody: DefaultBodyType; + const env = createMockEnvironment({ Id: 42, Name: 'removable-env' }); + + server.use( + http.post('/api/endpoint_groups', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ + Id: 1, + Name: 'test-group', + TagIds: [], + Policies: [], + }); + }) + ); + + const user = userEvent.setup(); + renderCreateGroupView({ availableEnvironments: [env] }); + + const nameInput = await screen.findByLabelText(/Name/i); + await user.type(nameInput, 'test-group'); + + // Add the environment via drawer + const addBtn = await screen.findByTestId('add-environments-button'); + await user.click(addBtn); + await screen.findByText('removable-env'); + const drawerTable = screen.getByTestId('add-environments-drawer-table'); + const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox'); + await user.click(drawerCheckboxes[1]); + const confirmBtn = screen.getByTestId('add-environments-confirm-button'); + await waitFor(() => expect(confirmBtn).toBeEnabled()); + await user.click(confirmBtn); + + // Environment now appears in the associated list — select its row checkbox and remove it + await screen.findByText('removable-env'); + const assocCheckboxes = screen.getAllByRole('checkbox'); + await user.click(assocCheckboxes[assocCheckboxes.length - 1]); + const removeBtn = await screen.findByTestId('remove-environments-button'); + await waitFor(() => expect(removeBtn).toBeEnabled()); + await user.click(removeBtn); + + // Submit — payload should have no environments + const submitBtn = screen.getByRole('button', { name: /Create/i }); + await waitFor(() => expect(submitBtn).toBeEnabled()); + await user.click(submitBtn); + + await waitFor(() => { + expect(requestBody).toMatchObject({ + Name: 'test-group', + AssociatedEndpoints: [], + }); + }); + }); + }); }); diff --git a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx index db2ae7e914..1a833b7b0c 100644 --- a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx +++ b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx @@ -30,19 +30,17 @@ export function CreateGroupView() { ]} /> -
-
- - - - - -
+
+ + + + +
); diff --git a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx index 081db394e7..89c1191458 100644 --- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx @@ -31,6 +31,28 @@ vi.mock('@/portainer/services/notifications', () => ({ notifySuccess: vi.fn(), })); +vi.mock('@@/Link', () => ({ + Link: ({ + children, + className, + ...props + }: { + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +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, - { ...mockEnvironment, Id: 2, Name: 'Env 2' } as Partial, - ], - }); - - // 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'); + }); + }); }); diff --git a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx index 577e5682b9..78fde0b16b 100644 --- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx +++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx @@ -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 = useMemo( + () => [ + { + name: 'Environments', + icon: Box, + widget: ( + setAddEnvsDrawerOpen(false)} + /> + ), + selectedTabParam: 'environments', + }, + ], + [addEnvsDrawerOpen] + ); + + const currentTabIndex = useCurrentTabIndex(tabs); return ( <> - +
+ groupQuery.refetch()} + onAddEnvironments={() => setAddEnvsDrawerOpen(true)} + onDelete={handleDeleteGroup} /> - - -
-
- - - {groupQuery.isError && ( - - Failed to load group details - - )} - {!groupQuery.isError && groupQuery.data && ( - - )} - - -
-
- -
-
- -
+ + {tabs[currentTabIndex].widget}
); - - 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 - ) { - 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'); - }, - } - ); - } } diff --git a/app/react/portainer/environments/environment-groups/ItemView/GroupHeader.tsx b/app/react/portainer/environments/environment-groups/ItemView/GroupHeader.tsx new file mode 100644 index 0000000000..4be84b12fb --- /dev/null +++ b/app/react/portainer/environments/environment-groups/ItemView/GroupHeader.tsx @@ -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 ( + + {tag?.Name ?? `Tag ${tagId}`} + + ); + }) + : undefined; + + const actionBar = group ? ( + <> +
+ + +
+
+ +
+ + ) : undefined; + + return ( + } + 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} + /> + ); +} diff --git a/app/react/portainer/environments/environment-groups/ItemView/tabs/EnvironmentsTab.tsx b/app/react/portainer/environments/environment-groups/ItemView/tabs/EnvironmentsTab.tsx new file mode 100644 index 0000000000..d0b05830b7 --- /dev/null +++ b/app/react/portainer/environments/environment-groups/ItemView/tabs/EnvironmentsTab.tsx @@ -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 ( + <> + + + {groupQuery.data && ( + + )} + + + +
+ +
+ + ); + + async function handleSubmit( + values: GroupFormValues, + { resetForm }: FormikHelpers + ) { + 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(); + }, + } + ); + } +} diff --git a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.tsx b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.tsx index 8d32768e72..c2a0982505 100644 --- a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.tsx +++ b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.tsx @@ -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) { /> setDrawerOpen(false)} + open={isDrawerOpen} + onClose={closeDrawer} excludeGroupIds={[groupId]} onAdd={handleAdd} isLoading={updateMutation.isLoading} diff --git a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsTable.tsx b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsTable.tsx index e81e7876f7..c66bdd1f8a 100644 --- a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsTable.tsx +++ b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsTable.tsx @@ -46,60 +46,57 @@ export function AssociatedEnvironmentsTable({ const columns = useMemo(() => buildColumns(), []); return ( - // avoid padding issues with the widget -
- - disableSelect={readOnly} - isLoading={isLoading} - title={title} - columns={columns} - settingsManager={tableState} - dataset={environments} - getRowId={(row) => String(row.Id)} - renderRow={(row) => ( - - cells={row.getVisibleCells()} - onClick={() => row.toggleSelected()} - className={clsx({ active: row.getIsSelected() })} - aria-selected={row.getIsSelected()} - /> - )} - extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)} - renderTableActions={(selectedItems) => - readOnly ? null : ( - <> - {confirmRemove ? ( - handleRemove(selectedItems)} - data-cy="remove-environments-button" - type="button" - /> - ) : ( - { - handleRemove(selectedItems); - }} - data-cy="remove-environments-button" - type="button" - /> - )} - - - ) - } - data-cy={dataCy || 'environment-table'} - /> -
+ + disableSelect={readOnly} + isLoading={isLoading} + title={title} + columns={columns} + settingsManager={tableState} + dataset={environments} + getRowId={(row) => String(row.Id)} + renderRow={(row) => ( + + cells={row.getVisibleCells()} + onClick={() => row.toggleSelected()} + className={clsx({ active: row.getIsSelected() })} + aria-selected={row.getIsSelected()} + /> + )} + extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)} + renderTableActions={(selectedItems) => + readOnly ? null : ( + <> + {confirmRemove ? ( + handleRemove(selectedItems)} + data-cy="remove-environments-button" + type="button" + /> + ) : ( + { + handleRemove(selectedItems); + }} + data-cy="remove-environments-button" + type="button" + /> + )} + + + ) + } + data-cy={dataCy || 'environment-table'} + /> ); function handleRemove(selectedItems: EnvironmentTableData[]) { diff --git a/app/react/portainer/environments/environment-groups/queries/query-keys.ts b/app/react/portainer/environments/environment-groups/queries/query-keys.ts index 504cb5d73a..bb534997d1 100644 --- a/app/react/portainer/environments/environment-groups/queries/query-keys.ts +++ b/app/react/portainer/environments/environment-groups/queries/query-keys.ts @@ -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, }; diff --git a/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts index 897797a051..0009214906 100644 --- a/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts +++ b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts @@ -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)]) ) ); } diff --git a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts index 47bb005c2b..942bc6c80e 100644 --- a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts +++ b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts @@ -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>(buildUrl()); + const { data } = await axios.get>(buildUrl(), { + params: { size: true }, + }); return data; } catch (e) { throw parseAxiosError(e, 'Unable to retrieve environment groups'); diff --git a/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts index 0819e129d5..b648cade35 100644 --- a/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts +++ b/app/react/portainer/environments/environment-groups/queries/useUpdateGroupMutation.ts @@ -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'); },