Compare commits

...

6 Commits

27 changed files with 896 additions and 700 deletions

View File

@@ -3,8 +3,10 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
streamOpts := remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
}
// Try WebSocket executor first, fall back to SPDY if it fails
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
if err != nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
if err == nil {
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err == nil {
return
}
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
}
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
})
// Fall back to SPDY executor
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil {
var exitError utilexec.ExitError
if !errors.As(err, &exitError) {
errChan <- errors.New("unable to start exec process")
errChan <- fmt.Errorf("unable to start exec process: %w", err)
}
}
}

View File

@@ -576,7 +576,7 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`

View File

@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
}
}
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
}); err != nil {
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
}
return err
}
return nil
})
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {

View File

@@ -66,7 +66,6 @@
.pagination > .active > span:focus,
.pagination > .active > button:focus {
@apply text-blue-7;
z-index: 3;
cursor: default;
/* background-color: var(--text-pagination-span-color); */
background-color: var(--bg-pagination-color);

View File

@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={clsx(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'fixed z-50 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {

View File

@@ -15,11 +15,10 @@ export function StickyFooter({
<div
className={clsx(
styles.actionBar,
// The sticky footer should be below the modal overlay `Modal.tsx` and react select menu `ReactSelect.css` (z-50)
'fixed bottom-0 right-0 z-10 h-16',
'fixed bottom-0 right-0 z-40 h-16',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
className
)}
>

View File

@@ -1,12 +1,13 @@
import { Trash2 } from 'lucide-react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { confirmDelete } from '@@/modals/confirm';
import { Button } from './Button';
import { LoadingButton } from './LoadingButton';
import { Button } from './Button';
type ConfirmOrClick =
| {
@@ -26,7 +27,10 @@ export function DeleteButton({
size,
children,
isLoading,
text = 'Remove',
loadingText = 'Removing...',
icon = false,
type,
'data-cy': dataCy,
...props
}: PropsWithChildren<
@@ -35,7 +39,10 @@ export function DeleteButton({
size?: ComponentProps<typeof Button>['size'];
disabled?: boolean;
isLoading?: boolean;
text?: string;
loadingText?: string;
icon?: boolean;
type?: ComponentProps<typeof Button>['type'];
}
>) {
if (isLoading === undefined) {
@@ -46,10 +53,11 @@ export function DeleteButton({
disabled={disabled || isLoading}
onClick={() => handleClick()}
icon={Trash2}
className="!m-0"
className={clsx('!m-0', icon ? 'btn-icon' : '')}
data-cy={dataCy}
type={type}
>
{children || 'Remove'}
{children || text}
</Button>
);
}
@@ -65,6 +73,7 @@ export function DeleteButton({
data-cy={dataCy}
isLoading={isLoading}
loadingText={loadingText}
type={type}
>
{children || 'Remove'}
</LoadingButton>

View File

@@ -7,31 +7,43 @@ interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[];
className?: string;
onClick?: () => void;
'aria-selected'?: boolean;
}
export function TableRow<D extends DefaultType = DefaultType>({
cells,
className,
onClick,
'aria-selected': ariaSelected,
}: Props<D>) {
return (
<tr
className={clsx(className, { 'cursor-pointer': !!onClick })}
onClick={onClick}
aria-selected={ariaSelected}
>
{cells.map((cell) => (
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{cells.map((cell) => {
const { className, width } = parseMeta(cell.column.columnDef.meta);
return (
<td key={cell.id} className={className} style={{ width }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
function getClassName<D extends DefaultType = DefaultType>(
function parseMeta<D extends DefaultType = DefaultType>(
meta: ColumnMeta<D, unknown> | undefined
) {
return !!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
const className =
!!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
const width =
!!meta && 'width' in meta && typeof meta.width === 'string'
? meta.width
: undefined;
return { className, width };
}

View File

@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
),
enableHiding: false,
meta: {
width: 50,
width: '50px',
},
};
}

View File

@@ -52,7 +52,7 @@ export function FormSection({
</FormSectionTitle>
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
{isExpanded && <div className="clear-both">{children}</div>}
<div className="clear-both">{isExpanded && children}</div>
</div>
);
}

View File

@@ -37,11 +37,11 @@ export function Modal({
<Context.Provider value>
<DialogOverlay
isOpen
className={clsx(
styles.overlay,
'flex items-center justify-center z-50'
)}
className={clsx(styles.overlay, 'flex items-center justify-center')}
onDismiss={onDismiss}
// When a Sheet is open and then a Modal opens, Radix DismissableLayer sets body.style.pointerEvents="none" for this modal overlay, so make it auto here.
// z-index ensures the modal renders above the base views and any Sheet (z-50).
style={{ zIndex: 60, pointerEvents: 'auto' }}
>
<DialogContent
aria-label={ariaLabel}

View File

@@ -30,7 +30,7 @@ export function CreateGroupView() {
]}
/>
<div className="row">
<div className="row pb-20">
<div className="col-sm-12">
<Widget>
<Widget.Body>

View File

@@ -298,14 +298,13 @@ describe('EditGroupView', () => {
await user.click(submitButton);
// Verify the request URL and body
// Verify the request URL and body.
await waitFor(() => {
expect(requestUrl).toBe('/api/endpoint_groups/2');
expect(requestBody).toEqual({
Name: 'Updated Group',
Description: 'Test description',
TagIDs: [1],
AssociatedEndpoints: [1], // The associated environment ID
});
});
});
@@ -451,22 +450,9 @@ describe('EditGroupView', () => {
expect(elements[0]).toBeVisible();
});
it('should include associated environment IDs in update payload', async () => {
it('should NOT include AssociatedEndpoints in update payload (backend preserves associations)', async () => {
let requestBody: DefaultBodyType;
const associatedEnvs = [
{
...mockEnvironment,
Id: 100,
Name: 'Env 100',
} as Partial<Environment>,
{
...mockEnvironment,
Id: 200,
Name: 'Env 200',
} as Partial<Environment>,
];
server.use(
http.put('/api/endpoint_groups/:id', async ({ request }) => {
requestBody = await request.json();
@@ -475,9 +461,7 @@ describe('EditGroupView', () => {
);
const user = userEvent.setup();
renderEditGroupView({
associatedEnvironments: associatedEnvs,
});
renderEditGroupView();
// Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i);
@@ -495,11 +479,9 @@ describe('EditGroupView', () => {
await user.click(submitButton);
// Verify the associated environments are included in payload
// Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: [100, 200],
});
expect(requestBody).not.toHaveProperty('AssociatedEndpoints');
});
});
});

View File

@@ -2,8 +2,6 @@ import { useRouter } from '@uirouter/react';
import { useMemo } from 'react';
import { FormikHelpers } from 'formik';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam';
import { Widget } from '@@/Widget';
@@ -13,32 +11,22 @@ import { Alert } from '@@/Alert';
import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
import { GroupForm, GroupFormValues } from '../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
export function EditGroupView() {
const groupId = useIdParam();
const router = useRouter();
const groupQuery = useGroup(groupId);
const updateMutation = useUpdateGroupMutation();
// Fetch associated environments for this group (not for unassigned group)
const isUnassignedGroup = groupId === 1;
const environmentsQuery = useEnvironmentList(
{ groupIds: [groupId], pageLimit: 0 },
{ enabled: !!groupId && !isUnassignedGroup }
);
const isLoading =
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
const updateMutation = useUpdateGroupMutation();
const initialValues: GroupFormValues = useMemo(
() => ({
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [],
associatedEnvironments:
environmentsQuery.environments?.map((e) => e.Id) ?? [],
}),
[groupQuery.data, environmentsQuery.environments]
[groupQuery.data]
);
return (
@@ -54,7 +42,7 @@ export function EditGroupView() {
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body loading={isLoading}>
<Widget.Body loading={groupQuery.isLoading}>
{groupQuery.isError && (
<Alert color="error" title="Error">
Failed to load group details
@@ -73,6 +61,15 @@ export function EditGroupView() {
</Widget>
</div>
</div>
<div className="row pb-20">
<div className="col-sm-12">
<AssociatedEnvironmentsSelector
groupId={groupId}
readOnly={isUnassignedGroup}
/>
</div>
</div>
</>
);
@@ -86,12 +83,11 @@ export function EditGroupView() {
name: values.name,
description: values.description,
tagIds: values.tagIds,
associatedEnvironments: values.associatedEnvironments,
// associatedEnvironments omitted — backend preserves existing when field is absent (nil)
},
{
onSuccess() {
resetForm();
notifySuccess('Success', 'Group successfully updated');
router.stateService.go('portainer.groups');
},
}

View File

@@ -0,0 +1,174 @@
import clsx from 'clsx';
import { useState } from 'react';
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { TableRow } from '@@/datatables/TableRow';
import { Sheet, SheetContent, SheetClose, SheetHeader } from '@@/Sheet';
import { Button, LoadingButton } from '@@/buttons';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const columns = [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => (
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
),
}),
];
interface Props {
open: boolean;
onClose(): void;
/** IDs already in the group — excluded from the available list */
excludeIds: EnvironmentId[];
/** Called with the full env objects so callers can display names or extract IDs.
* Returns true if the add was committed, false if the user cancelled. */
onAdd:
| ((envs: EnvironmentTableData[]) => Promise<boolean>)
| ((envs: EnvironmentTableData[]) => void);
/** Loading state from the parent — disables buttons and shows spinner */
isLoading?: boolean;
}
export function AddEnvironmentsDrawer({
open,
onClose,
excludeIds,
onAdd,
isLoading,
}: Props) {
const tableState = useTableStateWithoutStorage('Name');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedEnvs, setSelectedEnvs] = useState<EnvironmentTableData[]>([]);
const [page, setPage] = useState(0);
const {
environments,
totalCount,
isLoading: isEnvsLoading,
} = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
function handleSelectionChange(ids: string[]) {
setSelectedIds(ids);
const currentDataMap = new Map(
(environments ?? []).map((env) => [String(env.Id), env])
);
setSelectedEnvs((prev) => {
const prevMap = new Map(prev.map((e) => [String(e.Id), e]));
// Keep already-tracked envs that remain selected
const kept = prev.filter((e) => ids.includes(String(e.Id)));
// Add newly selected envs from the current page
const added = ids
.filter((id) => !prevMap.has(id) && currentDataMap.has(id))
.map((id) => currentDataMap.get(id)!);
return [...kept, ...added];
});
}
async function handleAdd() {
const committed = await onAdd(selectedEnvs);
// Close only if the add was committed or there was no confirmation needed
if (committed || committed === undefined) {
resetSelection();
onClose();
}
}
function resetSelection() {
setSelectedIds([]);
setSelectedEnvs([]);
}
return (
<Sheet
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetSelection();
onClose();
}
}}
>
<SheetContent className="flex flex-col !p-0">
<div className="flex-1 p-4 overflow-auto">
<SheetHeader title="Add environments" />
<Datatable<EnvironmentTableData>
title="Available environments"
columns={columns}
dataset={environments ?? []}
settingsManager={tableState}
isLoading={isEnvsLoading}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
extendTableOptions={withControlledSelected(
handleSelectionChange,
selectedIds
)}
data-cy="add-environments-drawer-table"
/>
</div>
{/* Don't use StickyFooter here. StickyFooter has classes for the menu to the left that we don't want here */}
<div
className={clsx(
'bottom-0 left-0 right-0 w-full z-50 h-16 sticky justify-end gap-4',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]'
)}
>
<SheetClose asChild>
<Button
color="default"
disabled={isLoading}
data-cy="add-environments-cancel-button"
size="medium"
>
Cancel
</Button>
</SheetClose>
<LoadingButton
onClick={handleAdd}
disabled={selectedIds.length === 0 || isLoading}
isLoading={!!isLoading}
loadingText="Adding..."
data-cy="add-environments-confirm-button"
size="medium"
>
Confirm
</LoadingButton>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { vi } from 'vitest';
@@ -11,227 +11,204 @@ import {
EnvironmentId,
} from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '../../types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
function createEnv(id: EnvironmentId, name: string): Environment {
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 });
vi.mock('@@/modals/confirm', () => ({
openConfirm: vi.fn().mockResolvedValue(true),
confirmDelete: vi.fn().mockResolvedValue(true),
}));
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
Description: '',
TagIds: [],
};
function createEnv(id: EnvironmentId, name: string): Partial<Environment> {
return createMockEnvironment({ Id: id, Name: name });
}
function setupMockServer(environments: Array<Environment> = []) {
function setupMockServer({
associatedEnvs = [] as Array<Partial<Environment>>,
availableEnvs = [] as Array<Partial<Environment>>,
onPut = undefined as ((body: unknown) => void) | undefined,
} = {}) {
server.use(
http.get('/api/endpoints', () =>
HttpResponse.json(environments, {
headers: {
'x-total-count': String(environments.length),
'x-total-available': String(environments.length),
},
})
)
http.get('/api/endpoint_groups/2', () => HttpResponse.json(mockGroup)),
http.get('/api/endpoints', ({ request }) => {
const url = new URL(request.url);
const groupIds = [
...url.searchParams.getAll('groupIds'),
...url.searchParams.getAll('groupIds[]'),
];
function makeResponse(envs: Array<Partial<Environment>>) {
return HttpResponse.json(envs, {
headers: {
'x-total-count': String(envs.length),
'x-total-available': String(envs.length),
},
});
}
if (groupIds.includes('2')) return makeResponse(associatedEnvs);
if (groupIds.includes('1')) return makeResponse(availableEnvs);
return makeResponse([]);
}),
http.put('/api/endpoint_groups/2', async ({ request }) => {
const body = await request.json();
onPut?.(body);
return HttpResponse.json(mockGroup);
})
);
}
function renderComponent({
associatedEnvironmentIds = [] as Array<EnvironmentId>,
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
onChange = vi.fn(),
}: {
associatedEnvironmentIds?: Array<EnvironmentId>;
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
onChange?: (ids: Array<EnvironmentId>) => void;
} = {}) {
function renderComponent(groupId = 2) {
const Wrapped = withTestQueryProvider(() => (
<AssociatedEnvironmentsSelector
associatedEnvironmentIds={associatedEnvironmentIds}
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
onChange={onChange}
/>
<AssociatedEnvironmentsSelector groupId={groupId} readOnly={false} />
));
return {
...render(<Wrapped />),
onChange,
};
return render(<Wrapped />);
}
describe('AssociatedEnvironmentsSelector', () => {
describe('Rendering', () => {
it('should render both Available and Associated environments tables', async () => {
it('renders the associated environments table', async () => {
setupMockServer();
renderComponent();
expect(
screen.getByRole('heading', { name: 'Available environments' })
).toBeVisible();
expect(
screen.getByRole('heading', { name: 'Associated environments' })
await screen.findByRole('heading', { name: 'Associated environments' })
).toBeVisible();
});
it('should render instruction text', async () => {
it('renders an Add button', async () => {
setupMockServer();
renderComponent();
expect(
await screen.findByText(/click on any environment entry to move it/i)
await screen.findByTestId('add-environments-button')
).toBeInTheDocument();
});
it('should render Associated environments table with data-cy attribute', async () => {
setupMockServer();
it('renders a Remove button that is initially disabled', async () => {
setupMockServer({ associatedEnvs: [createEnv(10, 'my-env')] });
renderComponent();
await waitFor(() => {
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
});
await screen.findByText('my-env');
const removeBtn = screen.getByTestId('remove-environments-button');
expect(removeBtn).toBeDisabled();
});
it('should display initially associated environments in Associated table', async () => {
const envs = [createEnv(10, 'associated-env-1')];
it('displays environments returned by the API', async () => {
setupMockServer({ associatedEnvs: [createEnv(10, 'env-alpha')] });
renderComponent();
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
});
await waitFor(() => {
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
});
});
});
describe('Adding environments', () => {
it('should call onChange with new environment ID when clicking an available environment', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env')];
setupMockServer(envs);
renderComponent({ onChange });
const envRow = await screen.findByText('available-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([1]);
});
it('should append new environment to existing associated IDs', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
});
// Wait for the available table to be ready and find the row
const availableTable = await screen.findByTestId(
'group-availableEndpoints'
);
await within(availableTable).findByText('available-env');
// Find the row element that contains the text and click it
const rows = within(availableTable).getAllByRole('row');
const envRow = rows.find(
(row) => row.textContent?.includes('available-env')
);
expect(envRow).toBeDefined();
await user.click(envRow!);
// Wait for onChange to be called with the new environment ID appended
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([10, 1]);
});
expect(await screen.findByText('env-alpha')).toBeInTheDocument();
});
});
describe('Removing environments', () => {
it('should call onChange without the removed environment ID', async () => {
it('enables Remove button when a row is selected', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
setupMockServer({ associatedEnvs: [createEnv(10, 'env-to-remove')] });
renderComponent();
const envs = [
createEnv(10, 'associated-env-1'),
createEnv(11, 'associated-env-2'),
];
setupMockServer(envs);
await screen.findByText('env-to-remove');
renderComponent({
associatedEnvironmentIds: [10, 11],
initialAssociatedEnvironmentIds: [10, 11],
onChange,
// First checkbox is the select-all header, second is the first row
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]);
await waitFor(() => {
expect(screen.getByTestId('remove-environments-button')).toBeEnabled();
});
// Wait for initial query to load and row to appear in Associated table, then click
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow =
await within(associatedTable).findByText('associated-env-1');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([11]);
});
it('should call onChange with empty array when removing last environment', async () => {
it('calls PUT with filtered environment IDs after confirming remove', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
let requestBody: unknown;
const envs = [createEnv(10, 'only-env')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
setupMockServer({
associatedEnvs: [createEnv(10, 'env-a'), createEnv(11, 'env-b')],
onPut: (body) => {
requestBody = body;
},
});
renderComponent();
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow = await within(associatedTable).findByText('only-env');
await user.click(envRow);
await screen.findByText('env-a');
expect(onChange).toHaveBeenCalledWith([]);
// Select first row checkbox
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]);
const removeBtn = await screen.findByTestId('remove-environments-button');
await waitFor(() => expect(removeBtn).toBeEnabled());
await user.click(removeBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: [11],
});
});
});
});
describe('Computed values', () => {
it('should identify added IDs (current but not initial)', () => {
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id))
// When current=[1,2,3] and initial=[2,3], added=[1]
setupMockServer();
describe('Adding environments (drawer)', () => {
it('opens the drawer when Add button is clicked', async () => {
const user = userEvent.setup();
setupMockServer({ availableEnvs: [createEnv(20, 'available-env')] });
renderComponent();
// This test validates the component's internal logic by checking the highlightIds
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge)
renderComponent({
associatedEnvironmentIds: [1, 2, 3],
initialAssociatedEnvironmentIds: [2, 3],
});
const addBtn = await screen.findByTestId('add-environments-button');
await user.click(addBtn);
// The component will compute addedIds=[1] internally
// We can't directly test internal state, but we verify it renders
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
expect(await screen.findByText('Add environments')).toBeVisible();
});
it('should identify removed IDs (initial but not current)', () => {
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id))
// When current=[2,3] and initial=[1,2,3], removed=[1]
setupMockServer();
it('calls PUT with merged IDs when environments are added from drawer', async () => {
const user = userEvent.setup();
let requestBody: unknown;
renderComponent({
associatedEnvironmentIds: [2, 3],
initialAssociatedEnvironmentIds: [1, 2, 3],
setupMockServer({
associatedEnvs: [createEnv(10, 'existing-env')],
availableEnvs: [createEnv(20, 'new-env')],
onPut: (body) => {
requestBody = body;
},
});
renderComponent();
// The component will compute removedIds=[1] internally
// and pass it as includeIds to AvailableEnvironmentsTable
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible();
// Open drawer
const addBtn = await screen.findByTestId('add-environments-button');
await user.click(addBtn);
// Wait for drawer to open and available env to appear
await screen.findByText('Add environments');
await screen.findByText('new-env');
// Select the available env — find the drawer's checkboxes
// The drawer table has its own checkboxes after the main table ones
const allCheckboxes = screen.getAllByRole('checkbox');
// Last checkbox belongs to the drawer table row
await user.click(allCheckboxes[allCheckboxes.length - 1]);
// Click the Add button in the drawer footer
const confirmAddBtn = screen.getByTestId(
'add-environments-confirm-button'
);
await user.click(confirmAddBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: expect.arrayContaining([10, 20]),
});
});
});
});
});

View File

@@ -1,126 +1,94 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { openConfirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { Environment, EnvironmentId } from '../../../types';
import { useGroup } from '../../queries/useGroup';
import { useUpdateGroupMutation } from '../../queries/useUpdateGroupMutation';
import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable';
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props {
/** Group ID when editing an existing group */
groupId?: number;
/** IDs of currently associated environments */
associatedEnvironmentIds: Array<EnvironmentId>;
/** IDs of initially associated environments for tracking unsaved changes */
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
/** Called when environment IDs change */
onChange: (ids: Array<EnvironmentId>) => void;
groupId: EnvironmentGroupId;
/* For unassigned group, don't show the add/remove buttons and hide the checkbox */
readOnly: boolean;
}
export function AssociatedEnvironmentsSelector({
groupId,
associatedEnvironmentIds,
initialAssociatedEnvironmentIds,
onChange,
}: Props) {
// Track full environment objects for display (populated when clicking rows)
const [environmentCache, setEnvironmentCache] = useState<
Map<EnvironmentId, EnvironmentTableData>
>(new Map());
export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
// Fetch initially associated environments to populate the cache
const initialEnvsQuery = useEnvironmentList(
groupId
? {
groupIds: [groupId],
pageLimit: 0,
}
: {
endpointIds: initialAssociatedEnvironmentIds,
},
{
enabled: groupId
? groupId !== 1
: initialAssociatedEnvironmentIds.length > 0,
}
);
const groupQuery = useGroup(groupId);
const environmentsQuery = useEnvironmentList({
groupIds: [groupId],
pageLimit: 0,
});
const updateMutation = useUpdateGroupMutation();
const environmentMap = useMemo(
() => buildEnvironmentMap(environmentCache, initialEnvsQuery.environments),
[environmentCache, initialEnvsQuery.environments]
);
const associatedSet = new Set(associatedEnvironmentIds);
const initialSet = new Set(initialAssociatedEnvironmentIds);
const addedIds = associatedEnvironmentIds.filter((id) => !initialSet.has(id));
const removedIds = initialAssociatedEnvironmentIds.filter(
(id) => !associatedSet.has(id)
);
const excludeIdsForAvailableEnvironments = groupId
? addedIds
: associatedEnvironmentIds;
const associatedEnvironments = associatedEnvironmentIds
.map((id) => environmentMap.get(id))
.filter((env): env is Environment => env !== undefined);
const currentEnvironments = environmentsQuery.environments ?? [];
const currentIds = currentEnvironments.map((e) => e.Id);
return (
<FormSection title="Associated environments">
<div className="small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<>
<AssociatedEnvironmentsTable
title="Associated environments"
environments={currentEnvironments}
isLoading={environmentsQuery.isLoading}
onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
isRemoving={updateMutation.isLoading}
data-cy="group-associatedEndpoints"
readOnly={readOnly}
/>
<div className="flex mt-4 gap-5 items-stretch">
<div className="w-1/2 flex flex-col">
<AvailableEnvironmentsTable
title="Available environments"
excludeIds={excludeIdsForAvailableEnvironments}
includeIds={removedIds}
highlightIds={removedIds}
onClickRow={handleAddEnvironment}
data-cy="group-availableEndpoints"
/>
</div>
<div className="w-1/2 flex flex-col">
<AssociatedEnvironmentsTable
title="Associated environments"
environments={associatedEnvironments}
highlightIds={addedIds}
onClickRow={handleRemoveEnvironment}
data-cy="group-associatedEndpoints"
/>
</div>
</div>
</FormSection>
<AddEnvironmentsDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
excludeIds={currentIds}
onAdd={handleAdd}
isLoading={updateMutation.isLoading}
/>
</>
);
function handleAddEnvironment(env: EnvironmentTableData) {
if (!associatedEnvironmentIds.includes(env.Id)) {
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env));
onChange([...associatedEnvironmentIds, env.Id]);
}
function handleRemove(selected: EnvironmentTableData[]) {
const selectedIds = new Set(selected.map((e) => e.Id));
const remainingIds = currentIds.filter((id) => !selectedIds.has(id));
updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: remainingIds,
});
}
function handleRemoveEnvironment(env: EnvironmentTableData) {
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id));
async function handleAdd(newEnvs: EnvironmentTableData[]): Promise<boolean> {
const confirmed = await openConfirm({
title: 'Are you sure?',
message: `Are you sure you want to add the selected environment(s) to this group?`,
confirmButton: buildConfirmButton('Add'),
});
if (!confirmed) return false;
const mergedIds = [
...new Set([...currentIds, ...newEnvs.map((e) => e.Id)]),
];
updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: mergedIds,
});
return true;
}
}
function buildEnvironmentMap(
cache: Map<EnvironmentId, EnvironmentTableData>,
envs: Array<Environment> | undefined
): Map<EnvironmentId, EnvironmentTableData> {
return new Map([
...cache.entries(),
...(envs ?? []).map(
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
),
]);
}

View File

@@ -1,15 +1,17 @@
import { createColumnHelper } from '@tanstack/react-table';
import clsx from 'clsx';
import { truncate } from 'lodash';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { Datatable } from '@@/datatables';
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { TableRow } from '@@/datatables/TableRow';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Button } from '@@/buttons';
import { EnvironmentTableData } from './types';
@@ -18,64 +20,101 @@ const columnHelper = createColumnHelper<EnvironmentTableData>();
interface Props extends AutomationTestingProps {
title: string;
environments: Array<EnvironmentTableData>;
onClickRow?: (env: EnvironmentTableData) => void;
highlightIds?: Array<EnvironmentId>;
onRemove(selected: EnvironmentTableData[]): void;
onOpenAddDrawer(): void;
isRemoving?: boolean;
isLoading?: boolean;
/** When false, Remove fires immediately without a confirmation dialog (e.g. create mode) */
confirmRemove?: boolean;
/** When true, don't show the add/remove buttons and hide the checkbox */
readOnly?: boolean;
}
export function AssociatedEnvironmentsTable({
title,
environments,
onClickRow,
highlightIds = [],
onRemove,
onOpenAddDrawer,
isRemoving,
isLoading,
confirmRemove = true,
readOnly = false,
'data-cy': dataCy,
}: Props) {
const tableState = useTableStateWithoutStorage('Name');
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const columns = useMemo(() => buildColumns(), []);
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
// avoid padding issues with the widget
<div className="-mx-[15px]">
<Datatable<EnvironmentTableData>
disableSelect={readOnly}
isLoading={isLoading}
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'environment-table'}
/>
</div>
</Widget>
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
renderTableActions={(selectedItems) =>
readOnly ? null : (
<>
{confirmRemove ? (
<DeleteButton
disabled={selectedItems.length === 0}
isLoading={isRemoving}
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
onConfirmed={() => handleRemove(selectedItems)}
data-cy="remove-environments-button"
type="button"
/>
) : (
<DeleteButton
disabled={selectedItems.length === 0}
onClick={() => {
handleRemove(selectedItems);
}}
data-cy="remove-environments-button"
type="button"
/>
)}
<Button
icon={Plus}
onClick={onOpenAddDrawer}
data-cy="add-environments-button"
>
Add
</Button>
</>
)
}
data-cy={dataCy || 'environment-table'}
/>
</div>
);
function handleRemove(selectedItems: EnvironmentTableData[]) {
onRemove(selectedItems);
setSelectedIds([]);
}
}
function buildColumns(highlightIds: Array<EnvironmentId>) {
function buildColumns() {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.includes(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
cell: ({ getValue }) => (
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
),
}),
];

View File

@@ -1,176 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import clsx from 'clsx';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { semverCompare } from '@/react/common/semver-utils';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const tableKey = 'available-environments';
const settingsStore = createPersistedStore(tableKey, 'Name');
interface Props extends AutomationTestingProps {
title: string;
/** IDs to exclude from the query (environments already associated) */
excludeIds: Array<EnvironmentId>;
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
includeIds?: Array<EnvironmentId>;
/** IDs to highlight (unsaved badge) */
highlightIds?: Array<EnvironmentId>;
onClickRow?: (env: EnvironmentTableData) => void;
}
export function AvailableEnvironmentsTable({
title,
excludeIds,
includeIds = [],
highlightIds = [],
onClickRow,
'data-cy': dataCy,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
const [page, setPage] = useState(0);
const columns = useMemo(
() => buildColumns(new Set(highlightIds)),
[highlightIds]
);
// Query unassigned environments (group 1)
const unassignedQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
// Query removed environments by ID (these are still in their original group until saved)
const removedQuery = useEnvironmentList(
{
endpointIds: includeIds,
search: tableState.search,
},
{ enabled: includeIds.length > 0 }
);
// Merge results: removed environments + unassigned environments (deduped)
const { environments, uniqueRemovedCount } = useMemo(() => {
const unassigned = unassignedQuery.environments || [];
const removed =
includeIds.length > 0 ? removedQuery.environments || [] : [];
if (removed.length === 0) {
return { environments: unassigned, uniqueRemovedCount: 0 };
}
const unassignedIds = new Set(unassigned.map((e) => e.Id));
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
// Sort combined results by name to maintain order
const combined = [...uniqueRemoved, ...unassigned];
const isDesc = tableState.sortBy?.desc ?? false;
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
const sortKey = getSortKey(tableState.sortBy?.id);
if (sortKey) {
return {
environments: combined.sort((a, b) => {
const cmp = semverCompare(
a[sortKey].toString(),
b[sortKey].toString()
);
return isDesc ? -cmp : cmp;
}),
uniqueRemovedCount: uniqueRemoved.length,
};
}
return { environments: combined, uniqueRemovedCount: uniqueRemoved.length };
}, [
unassignedQuery.environments,
removedQuery.environments,
includeIds.length,
tableState.sortBy?.desc,
tableState.sortBy?.id,
]);
const totalCount = unassignedQuery.totalCount + uniqueRemovedCount;
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'available-environments-table'}
/>
</div>
</Widget>
);
}
function buildColumns(highlightIds: Set<EnvironmentId>) {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.has(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
),
}),
];
}
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
if (!sortId) {
return undefined;
}
switch (sortId) {
case 'Name':
return 'Name';
default:
return 'Name';
}
// extend to other keys as needed
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props {
selectedIds: EnvironmentId[];
onChange(ids: EnvironmentId[]): void;
}
/**
* Similar to AssociatedEnvironmentsSelector, but instead of making API calls and getting the environment list from the server, it holds the the selected environments and ids in local state.
*
* This is because on create, there is no group to add / remove environments from yet.
*/
export function FormModeEnvironmentsSelector({ selectedIds, onChange }: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedEnvironments, setSelectedEnvironments] = useState<
EnvironmentTableData[]
>([]);
return (
<FormSection title="Associate environments">
<p className="small text-muted">
Assocate environments to this group by clicking the add button below.
</p>
<AssociatedEnvironmentsTable
title="Associated environments"
environments={selectedEnvironments}
onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
confirmRemove={false}
data-cy="group-associatedEndpoints"
/>
<AddEnvironmentsDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
excludeIds={selectedIds}
onAdd={handleAdd}
/>
</FormSection>
);
async function handleRemove(toRemove: EnvironmentTableData[]) {
const removeIds = new Set(toRemove.map((env) => env.Id));
setSelectedEnvironments((prev) => prev.filter((e) => !removeIds.has(e.Id)));
onChange(selectedIds.filter((id) => !removeIds.has(id)));
}
function handleAdd(newEnvs: EnvironmentTableData[]) {
const existingIds = new Set(selectedIds);
const toAdd = newEnvs.filter((e) => !existingIds.has(e.Id));
setSelectedEnvironments((prev) => [...prev, ...toAdd]);
onChange([...selectedIds, ...toAdd.map((e) => e.Id)]);
}
}

View File

@@ -19,7 +19,6 @@ function renderGroupForm({
name: '',
description: '',
tagIds: [],
associatedEnvironments: [],
},
onSubmit = vi.fn(),
submitLabel = 'Create',
@@ -40,7 +39,7 @@ function renderGroupForm({
{ ID: 2, Name: 'staging' },
])
),
// Mock environments list for AssociatedEnvironmentsSelector
// Mock environments list for AssociatedEnvironmentsSelector and InlineAvailableEnvironmentsTable
http.get('/api/endpoints', () =>
HttpResponse.json([], {
headers: {
@@ -48,6 +47,16 @@ function renderGroupForm({
'x-total-available': '0',
},
})
),
// Mock group endpoint for AssociatedEnvironmentsSelector (edit mode)
http.get('/api/endpoint_groups/:id', ({ params }) =>
HttpResponse.json({
Id: Number(params.id),
Name: 'Test Group',
Description: '',
TagIds: [],
Policies: [],
})
)
);
@@ -88,48 +97,43 @@ describe('GroupForm', () => {
).toBeVisible();
});
it('should show Associated environments section when groupId is provided (not unassigned group)', async () => {
it('should not show Associated environments section when groupId is provided (edit mode)', async () => {
renderGroupForm({ groupId: 2 });
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
// In edit mode, environments are managed by AssociatedEnvironmentsSelector component (rendered separately in EditGroupView)
expect(
await screen.findByRole('heading', { name: /Associated environments/i })
).toBeVisible();
screen.queryByRole('heading', { name: /Associated environments/i })
).not.toBeInTheDocument();
expect(
screen.queryByTestId('add-environments-button')
).not.toBeInTheDocument();
});
it('should show Unassociated environments section when groupId is 1 (unassigned group)', async () => {
it('should not show environment section when groupId is 1 (unassigned group)', async () => {
renderGroupForm({ groupId: 1 });
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
// In edit mode, environments are managed by AssociatedEnvironmentsSelector component (rendered separately)
expect(
await screen.findByRole('heading', {
name: /Unassociated environments/i,
})
).toBeVisible();
// Should NOT show "Associated environments" section (exact match to exclude "Unassociated")
const associatedElements = screen.queryAllByText(
/^Associated environments$/i
);
expect(associatedElements).toHaveLength(0);
screen.queryByRole('heading', { name: /Associated environments/i })
).not.toBeInTheDocument();
expect(
screen.queryByTestId('add-environments-button')
).not.toBeInTheDocument();
});
it('should show Associated environments section in create mode (no groupId)', async () => {
it('should show associated environments table with Add button in create mode (no groupId)', async () => {
renderGroupForm();
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
// FormModeEnvironmentsSelector renders AssociatedEnvironmentsTable with an Add button
expect(
await screen.findByRole('heading', { name: /Associated environments/i })
).toBeVisible();
await screen.findByTestId('add-environments-button')
).toBeInTheDocument();
});
});
@@ -161,7 +165,6 @@ describe('GroupForm', () => {
name: 'existing-group',
description: '',
tagIds: [],
associatedEnvironments: [],
},
});
@@ -169,7 +172,6 @@ describe('GroupForm', () => {
name: /Create/i,
});
// Form is valid but not dirty, so should be disabled
expect(submitButton).toBeDisabled();
});
@@ -235,7 +237,6 @@ describe('GroupForm', () => {
name: 'test-group',
description: 'Test description',
tagIds: [],
associatedEnvironments: [],
}),
expect.anything()
);
@@ -244,7 +245,6 @@ describe('GroupForm', () => {
it('should show loading state during submission', async () => {
const user = userEvent.setup();
// Create a promise that we can control
let resolveSubmit: () => void;
const onSubmit = vi.fn().mockImplementation(
() =>
@@ -269,14 +269,12 @@ describe('GroupForm', () => {
await user.click(submitButton);
// Should show loading state
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Creating.../i })
).toBeVisible();
});
// Resolve the submission
resolveSubmit!();
});
});
@@ -306,7 +304,6 @@ describe('GroupForm', () => {
name: 'pre-filled-name',
description: 'pre-filled-description',
tagIds: [],
associatedEnvironments: [],
},
});

View File

@@ -9,26 +9,28 @@ import { object, string, array, number } from 'yup';
import { useRef } from 'react';
import { TagId } from '@/portainer/tags/types';
import {
EnvironmentId,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { useCanExit } from '@/react/hooks/useCanExit';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { FormSection } from '@@/form-components/FormSection';
import { TagSelector } from '@@/TagSelector';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { FormActions } from '@@/form-components/FormActions';
import { LoadingButton } from '@@/buttons';
import { StickyFooter } from '@@/StickyFooter/StickyFooter';
import { EnvironmentGroupId, EnvironmentId } from '../../types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
import { AvailableEnvironmentsTable } from './AssociatedEnvironmentsSelector/AvailableEnvironmentsTable';
import { FormModeEnvironmentsSelector } from './AssociatedEnvironmentsSelector/FormModeEnvironmentsSelector';
export interface GroupFormValues {
name: string;
description: string;
tagIds: Array<TagId>;
associatedEnvironments: Array<EnvironmentId>;
/** Used in create mode only — undefined in edit mode */
associatedEnvironments?: Array<EnvironmentId>;
}
interface Props {
@@ -48,7 +50,6 @@ const validationSchema = object({
name: string().required('Name is required'),
description: string(),
tagIds: array(number()),
associatedEnvironments: array(),
});
export function GroupForm({
@@ -71,7 +72,6 @@ export function GroupForm({
enableReinitialize
>
<InnerForm
initialValues={initialValues}
submitLabel={submitLabel}
submitLoadingLabel={submitLoadingLabel}
groupId={groupId}
@@ -81,20 +81,17 @@ export function GroupForm({
}
interface InnerFormProps {
initialValues: GroupFormValues;
submitLabel: string;
submitLoadingLabel: string;
groupId?: EnvironmentGroupId;
}
function InnerForm({
initialValues,
submitLabel,
submitLoadingLabel,
groupId,
}: InnerFormProps) {
const isPureAdmin = useIsPureAdmin();
const isUnassignedGroup = groupId === 1;
const {
values,
errors,
@@ -104,6 +101,7 @@ function InnerForm({
dirty,
isSubmitting,
} = useFormikContext<GroupFormValues>();
const isCreateMode = !groupId;
return (
<Form className="form-horizontal">
@@ -140,31 +138,25 @@ function InnerForm({
allowCreate={isPureAdmin}
/>
{isUnassignedGroup ? (
<FormSection title="Unassociated environments">
<AvailableEnvironmentsTable
title="Unassociated environments"
excludeIds={[]}
data-cy="group-unassociatedEndpoints"
/>
</FormSection>
) : (
<AssociatedEnvironmentsSelector
groupId={groupId}
associatedEnvironmentIds={values.associatedEnvironments}
initialAssociatedEnvironmentIds={initialValues.associatedEnvironments}
{isCreateMode && (
// Same UI as edit mode, but updates form values instead of the API
<FormModeEnvironmentsSelector
selectedIds={values.associatedEnvironments ?? []}
onChange={(ids) => setFieldValue('associatedEnvironments', ids)}
/>
)}
<FormActions
submitLabel={submitLabel}
loadingText={submitLoadingLabel}
isLoading={isSubmitting}
isValid={isValid && !isSubmitting && dirty}
errors={errors}
data-cy="group-submit-button"
/>
<StickyFooter className="justify-end gap-4">
<LoadingButton
size="medium"
loadingText={submitLoadingLabel}
isLoading={isSubmitting}
disabled={!isValid || isSubmitting || !dirty}
data-cy="group-submit-button"
>
{submitLabel}
</LoadingButton>
</StickyFooter>
</Form>
);
}

View File

@@ -2,8 +2,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentGroupId, EnvironmentId } from '../../types';
import { EnvironmentGroup } from '../types';
@@ -44,10 +45,11 @@ export function useUpdateGroupMutation() {
return useMutation({
mutationFn: updateGroup,
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.base());
queryClient.invalidateQueries(environmentQueryKeys.base());
notifySuccess('Success', 'Group successfully updated');
},
...withGlobalError('Failed to update group'),
...withInvalidate(queryClient, [
queryKeys.base(),
environmentQueryKeys.base(),
]),
});
}

View File

@@ -14,6 +14,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon';
import { Alert } from '@@/Alert';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -27,50 +31,78 @@ interface Props {
isDockerStandalone?: boolean;
}
const options: BoxSelectorOption<
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
>[] = _.compact([
type CreationType =
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync';
const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'edgeAgentStandard',
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
label: 'Edge Agent Standard',
description: '',
description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard',
},
isBE && {
id: 'edgeAgentAsync',
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
label: 'Edge Agent Async',
description: '',
value: 'edgeAgentAsync',
description:
'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync' as CreationType,
},
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{
id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />,
label: 'Agent',
description: '',
description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
value: 'agent',
},
{
id: 'api',
icon: <BadgeIcon icon={Network} size="3xl" />,
label: 'API',
description: '',
description: 'Connect to the environment directly via the Docker API.',
value: 'api',
},
{
id: 'socket',
icon: <BadgeIcon icon={Plug2} size="3xl" />,
label: 'Socket',
description: '',
description: 'Connect to the environment directly via the Docker socket.',
value: 'socket',
},
]);
];
const containerEngine = ContainerEngine.Docker;
export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType);
@@ -89,23 +121,43 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
)}
<BoxSelector
onChange={(v) => setCreationType(v)}
options={options}
options={primaryOptions}
value={creationType}
radioName="creation-type"
className="!-mb-2"
/>
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
{tab}
</div>
);
function getTab(
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
function getTab(creationType: CreationType) {
switch (creationType) {
case 'agent':
return (

View File

@@ -3,6 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { server, http } from '@/setup-tests/server';
import { WizardKubernetes } from './WizardKubernetes';
@@ -37,9 +38,10 @@ function renderComponent() {
)
);
const Wrapped = withTestQueryProvider(() => (
const WithRouter = withTestRouter(() => (
<WizardKubernetes onCreate={() => {}} />
));
const Wrapped = withTestQueryProvider(WithRouter);
return render(<Wrapped />);
}

View File

@@ -15,6 +15,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelectorOption } from '@@/BoxSelector/types';
import { BoxSelector } from '@@/BoxSelector';
import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -32,21 +36,26 @@ type CreationType =
| 'agent'
| 'kubeconfig';
const options: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'agent_endpoint',
icon: Zap,
iconType: 'badge',
label: 'Agent',
value: 'agent',
description: '',
},
const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'edgeAgentStandard',
icon: EdgeAgentStandardIcon,
iconType: 'badge',
label: 'Edge Agent Standard',
description: '',
description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard',
},
isBE && {
@@ -54,22 +63,40 @@ const options: BoxSelectorOption<CreationType>[] = _.compact([
icon: EdgeAgentAsyncIcon,
iconType: 'badge',
label: 'Edge Agent Async',
description: '',
description:
'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync',
},
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{
id: 'agent_endpoint',
icon: Zap,
iconType: 'badge',
label: 'Agent',
value: 'agent',
description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
},
{
id: 'kubeconfig_endpoint',
icon: UploadCloud,
iconType: 'badge',
label: 'Import',
value: 'kubeconfig',
description: 'Import an existing Kubernetes config',
description: 'Import an existing Kubernetes config.',
feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG,
},
]);
];
export function WizardKubernetes({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType);
@@ -77,11 +104,38 @@ export function WizardKubernetes({ onCreate }: Props) {
<div className="form-horizontal">
<BoxSelector
onChange={(v) => setCreationType(v)}
options={options}
options={primaryOptions}
value={creationType}
radioName="creation-type"
className="!-mb-2"
/>
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
{tab}
</div>
);

View File

@@ -14,6 +14,10 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon';
import { TextTip } from '@@/Tip/TextTip';
import { FormSection } from '@@/form-components/FormSection';
import { Badge } from '@@/Badge';
import { ExternalLink } from '@@/ExternalLink';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@@ -25,43 +29,66 @@ interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
}
const options: BoxSelectorOption<
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
>[] = _.compact([
type CreationType = 'agent' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync';
const primaryOptions: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'edgeAgentStandard',
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
label: 'Edge Agent Standard',
description: '',
description: (
<>
<span>
<Badge type="infoSecondary">Recommended</Badge>{' '}
<Badge type="infoSecondary">Supports Policies</Badge>
</span>
<span className="mt-1 block">
The remote environment will initiate connections to the Portainer
server, with the ability to open a secure on-demand tunnel for
real-time interaction. The Portainer server must be accessible from
the Edge Agent environment.
</span>
</>
),
value: 'edgeAgentStandard',
},
isBE && {
id: 'edgeAgentAsync',
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
label: 'Edge Agent Async',
description: '',
description:
'The remote environment will initiate connections to the Portainer server, without the ability to open a real-time tunnel. The Portainer server must be accessible from the Edge Agent environment.',
value: 'edgeAgentAsync',
},
]);
const legacyOptions: BoxSelectorOption<CreationType>[] = [
{
id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />,
label: 'Agent',
description: '',
description:
'The Portainer Server will initiate connections to the remote environment. The agent on the remote environment must be accessible from the Portainer server environment.',
value: 'agent',
},
{
id: 'socket',
icon: <BadgeIcon icon={Plug2} size="3xl" />,
label: 'Socket',
description: '',
description: 'Connect to the environment directly via the Docker socket.',
value: 'socket',
},
]);
];
const containerEngine = ContainerEngine.Podman;
export function WizardPodman({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
const edgeAgentDocsUrl = useDocsUrl(
'/faqs/getting-started/why-do-we-recommend-using-the-edge-agent-instead-of-the-traditional-agent'
);
const [creationType, setCreationType] = useState<CreationType>(
primaryOptions[0].value
);
const tab = getTab(creationType);
@@ -69,10 +96,38 @@ export function WizardPodman({ onCreate }: Props) {
<div className="form-horizontal">
<BoxSelector
onChange={(v) => setCreationType(v)}
options={options}
options={primaryOptions}
value={creationType}
radioName="creation-type"
className="!-mb-2"
/>
<FormSection
key="legacy-options"
title="More options"
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
>
<p className="text-xs text-muted mb-2">
These are legacy options that don&apos;t support edge features or
policy management. For most use cases,{' '}
<ExternalLink
to={edgeAgentDocsUrl}
data-cy="wizard-edge-agent-docs-link"
>
the Edge Agent is recommended
</ExternalLink>
</p>
<BoxSelector
onChange={(v) => setCreationType(v)}
options={legacyOptions}
value={creationType}
radioName="creation-type"
/>
</FormSection>
<TextTip color="orange" className="mb-2" inline={false}>
Currently, Portainer only supports <b>Podman 5</b> running in rootful
(privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode
@@ -82,14 +137,7 @@ export function WizardPodman({ onCreate }: Props) {
</div>
);
function getTab(
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
function getTab(creationType: CreationType) {
switch (creationType) {
case 'agent':
return (