Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f99e298f3 | ||
|
|
21eb20b35e | ||
|
|
f85a7ea24c | ||
|
|
6aacb61c87 | ||
|
|
bb2c75ba93 | ||
|
|
16536c8a71 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary
|
||||
PolicyChartSummary `mapstructure:",squash"`
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||
),
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
width: 50,
|
||||
width: '50px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function CreateGroupView() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="row pb-20">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'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 (
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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'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 (
|
||||
|
||||
Reference in New Issue
Block a user