mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:50:12 +00:00
feat(environment-groups): replace Datatable with SortableList and update list UI [R8S-827] (#2661)
This commit is contained in:
+12
-16
@@ -2,23 +2,18 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
|
||||
@@ -47,27 +42,28 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
||||
}
|
||||
|
||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||
wt := NewNoSymlinkFS(osfs.New(dst))
|
||||
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
|
||||
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
|
||||
|
||||
_, err := git.CloneContext(ctx, storer, wt, opt)
|
||||
if c.preserveGitDirectory {
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, opt)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "failed to clone git repository")
|
||||
}
|
||||
|
||||
if c.preserveGitDirectory {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
|
||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
||||
// Memory storage avoids a macOS filesystem conflict where go-git's init
|
||||
// creates dst/.git as a directory before checkout, causing EISDIR errors
|
||||
// that mask ErrSymlinkDetected from noSymlinkFS.
|
||||
wt := NewNoSymlinkFS(osfs.New(dst))
|
||||
_, err := git.CloneContext(ctx, memory.NewStorage(), wt, opt)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
return errors.Wrap(err, "failed to clone git repository")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,29 @@ package endpointgroups
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
endpointutils "github.com/portainer/portainer/pkg/endpoints"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type endpointGroupTypeInfo struct {
|
||||
Docker int `json:"Docker"`
|
||||
Kubernetes int `json:"Kubernetes"`
|
||||
Podman int `json:"Podman"`
|
||||
Mixed bool `json:"Mixed"`
|
||||
}
|
||||
|
||||
type endpointGroupResponse struct {
|
||||
portainer.EndpointGroup
|
||||
Total int `json:"Total,omitzero"`
|
||||
TypeInfo endpointGroupTypeInfo `json:"TypeInfo,omitzero"`
|
||||
}
|
||||
|
||||
// @id EndpointGroupList
|
||||
// @summary List Environment(Endpoint) groups
|
||||
// @description List all environment(endpoint) groups based on the current user authorizations. Will
|
||||
@@ -18,13 +36,41 @@ import (
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {array} portainer.EndpointGroup "Environment(Endpoint) group"
|
||||
// @param size query boolean false "If true, each environment(endpoint) group will include the number of environments(endpoints) associated to it and breakdown by type"
|
||||
// @success 200 {array} endpointGroupResponse "Environment(Endpoint) group"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoint_groups [get]
|
||||
func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
||||
includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
return httperror.BadRequest("Invalid query parameter: size", err)
|
||||
}
|
||||
|
||||
var endpoints []portainer.Endpoint
|
||||
var endpointGroups []portainer.EndpointGroup
|
||||
var handlerErr *httperror.HandlerError
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var txErr error
|
||||
endpointGroups, txErr = handler.DataStore.EndpointGroup().ReadAll()
|
||||
if txErr != nil {
|
||||
handlerErr = httperror.InternalServerError("Unable to retrieve environment groups from the database", txErr)
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
if includeSize {
|
||||
endpoints, txErr = tx.Endpoint().Endpoints()
|
||||
if txErr != nil {
|
||||
handlerErr = httperror.InternalServerError("Unable to retrieve endpoints from the database", txErr)
|
||||
return handlerErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
return httperror.InternalServerError("Unable to retrieve data from the database", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
@@ -33,5 +79,69 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext)
|
||||
return response.JSON(w, endpointGroups)
|
||||
|
||||
if len(endpointGroups) == 0 {
|
||||
return response.JSON(w, []portainer.EndpointGroup{})
|
||||
}
|
||||
endpointGroupSet := set.Set[portainer.EndpointGroupID]{}
|
||||
if includeSize {
|
||||
for i := range endpointGroups {
|
||||
endpointGroupSet[endpointGroups[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var endpointGroupCountMap map[portainer.EndpointGroupID]int
|
||||
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo
|
||||
if includeSize {
|
||||
endpointGroupCountMap = make(map[portainer.EndpointGroupID]int)
|
||||
endpointGroupTypeInfoMap = make(map[portainer.EndpointGroupID]endpointGroupTypeInfo)
|
||||
for _, endpoint := range endpoints {
|
||||
if _, ok := endpointGroupSet[endpoint.GroupID]; !ok {
|
||||
continue
|
||||
}
|
||||
endpointGroupCountMap[endpoint.GroupID]++
|
||||
|
||||
typeInfo := endpointGroupTypeInfoMap[endpoint.GroupID]
|
||||
|
||||
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
typeInfo.Kubernetes++
|
||||
} else if endpoint.ContainerEngine == "podman" {
|
||||
typeInfo.Podman++
|
||||
} else {
|
||||
typeInfo.Docker++
|
||||
}
|
||||
endpointGroupTypeInfoMap[endpoint.GroupID] = typeInfo
|
||||
}
|
||||
|
||||
for groupID, typeInfo := range endpointGroupTypeInfoMap {
|
||||
var bits int
|
||||
if typeInfo.Docker > 0 {
|
||||
bits |= 1
|
||||
}
|
||||
if typeInfo.Kubernetes > 0 {
|
||||
bits |= 2
|
||||
}
|
||||
if typeInfo.Podman > 0 {
|
||||
bits |= 4
|
||||
}
|
||||
typeInfo.Mixed = bits&(bits-1) != 0
|
||||
endpointGroupTypeInfoMap[groupID] = typeInfo
|
||||
}
|
||||
}
|
||||
|
||||
endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups))
|
||||
for i := range endpointGroups {
|
||||
groupID := endpointGroups[i].ID
|
||||
|
||||
endpointGroupsResponse[i] = endpointGroupResponse{
|
||||
EndpointGroup: endpointGroups[i],
|
||||
}
|
||||
|
||||
if includeSize {
|
||||
endpointGroupsResponse[i].Total = endpointGroupCountMap[groupID]
|
||||
endpointGroupsResponse[i].TypeInfo = endpointGroupTypeInfoMap[groupID]
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, endpointGroupsResponse)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package endpointgroups
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandler_endpointGroupList(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
handler := setUpHandler(t, store)
|
||||
|
||||
groups := setUpGroups(t, store)
|
||||
|
||||
t.Run("with groups, no size flag", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups", nil)
|
||||
rrc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}
|
||||
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
res := make([]endpointGroupResponse, 0)
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||
require.Len(t, res, len(groups)+1, "should contain an additional default group")
|
||||
for _, group := range res {
|
||||
assert.Zero(t, group.Total)
|
||||
assert.Zero(t, group.TypeInfo.Docker)
|
||||
assert.Zero(t, group.TypeInfo.Kubernetes)
|
||||
assert.Zero(t, group.TypeInfo.Podman)
|
||||
assert.False(t, group.TypeInfo.Mixed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with size flag, no endpoints", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||
rrc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}
|
||||
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
res := make([]endpointGroupResponse, 0)
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||
for _, group := range res {
|
||||
assert.Zero(t, group.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with size flag and single docker endpoint", func(t *testing.T) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
GroupID: groups[0].ID,
|
||||
Type: portainer.DockerEnvironment,
|
||||
}
|
||||
require.NoError(t, store.Endpoint().Create(endpoint))
|
||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(endpoint.ID) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||
rrc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}
|
||||
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
res := make([]endpointGroupResponse, 0)
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||
|
||||
var group1 *endpointGroupResponse
|
||||
for i := range res {
|
||||
if res[i].ID == groups[0].ID {
|
||||
group1 = &res[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, group1)
|
||||
assert.Equal(t, 1, group1.Total)
|
||||
assert.Equal(t, 1, group1.TypeInfo.Docker)
|
||||
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
|
||||
assert.Equal(t, 0, group1.TypeInfo.Podman)
|
||||
assert.False(t, group1.TypeInfo.Mixed)
|
||||
})
|
||||
|
||||
t.Run("with mixed endpoint types", func(t *testing.T) {
|
||||
dockerEndpoint := &portainer.Endpoint{
|
||||
ID: 2,
|
||||
GroupID: groups[1].ID,
|
||||
Type: portainer.DockerEnvironment,
|
||||
}
|
||||
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
|
||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
|
||||
|
||||
k8sEndpoint := &portainer.Endpoint{
|
||||
ID: 3,
|
||||
GroupID: groups[1].ID,
|
||||
Type: portainer.KubernetesLocalEnvironment,
|
||||
}
|
||||
require.NoError(t, store.Endpoint().Create(k8sEndpoint))
|
||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(k8sEndpoint.ID) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||
rrc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}
|
||||
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
res := make([]endpointGroupResponse, 0)
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||
|
||||
var group2 *endpointGroupResponse
|
||||
for i := range res {
|
||||
if res[i].ID == groups[1].ID {
|
||||
group2 = &res[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, group2)
|
||||
assert.Equal(t, 2, group2.Total)
|
||||
assert.Equal(t, 1, group2.TypeInfo.Docker)
|
||||
assert.Equal(t, 1, group2.TypeInfo.Kubernetes)
|
||||
assert.Equal(t, 0, group2.TypeInfo.Podman)
|
||||
assert.True(t, group2.TypeInfo.Mixed, "should be marked as mixed when multiple types exist")
|
||||
})
|
||||
|
||||
t.Run("with podman endpoint", func(t *testing.T) {
|
||||
dockerEndpoint := &portainer.Endpoint{
|
||||
ID: 4,
|
||||
GroupID: groups[0].ID,
|
||||
Type: portainer.DockerEnvironment,
|
||||
}
|
||||
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
|
||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
|
||||
|
||||
podmanEndpoint := &portainer.Endpoint{
|
||||
ID: 5,
|
||||
GroupID: groups[0].ID,
|
||||
Type: portainer.DockerEnvironment,
|
||||
ContainerEngine: "podman",
|
||||
}
|
||||
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
|
||||
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||
rrc := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
}
|
||||
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
res := make([]endpointGroupResponse, 0)
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||
|
||||
var group1 *endpointGroupResponse
|
||||
for i := range res {
|
||||
if res[i].ID == groups[0].ID {
|
||||
group1 = &res[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, group1)
|
||||
assert.Equal(t, 2, group1.Total)
|
||||
assert.Equal(t, 1, group1.TypeInfo.Docker)
|
||||
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
|
||||
assert.Equal(t, 1, group1.TypeInfo.Podman)
|
||||
assert.True(t, group1.TypeInfo.Mixed)
|
||||
})
|
||||
}
|
||||
|
||||
func setUpGroups(t *testing.T, store *datastore.Store) []portainer.EndpointGroup {
|
||||
group1 := &portainer.EndpointGroup{
|
||||
ID: 1,
|
||||
Name: "Group 1",
|
||||
}
|
||||
group2 := &portainer.EndpointGroup{
|
||||
ID: 2,
|
||||
Name: "Group 2",
|
||||
}
|
||||
require.NoError(t, store.EndpointGroup().Create(group1))
|
||||
require.NoError(t, store.EndpointGroup().Create(group2))
|
||||
|
||||
return []portainer.EndpointGroup{*group1, *group2}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package endpointgroups
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
func setUpHandler(t *testing.T, store *datastore.Store) *Handler {
|
||||
t.Helper()
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
return handler
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function PageTitle({
|
||||
children,
|
||||
}: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2 px-[15px]">
|
||||
<div className="mx-5 mb-2 flex items-center gap-2">
|
||||
<h1
|
||||
className="m-0 text-lg font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
||||
data-cy="page-title"
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.btn-blue {
|
||||
@apply border-blue-8 bg-blue-8 text-white;
|
||||
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
|
||||
@apply th-dark:border-blue-7 th-dark:bg-blue-7 th-dark:text-white th-dark:hover:border-blue-6 th-dark:hover:bg-blue-6 th-dark:hover:text-white;
|
||||
@apply th-highcontrast:border th-highcontrast:border-solid th-highcontrast:border-white th-highcontrast:bg-blue-8 th-highcontrast:text-white th-highcontrast:hover:bg-blue-9 th-highcontrast:hover:text-white;
|
||||
}
|
||||
|
||||
.btn-none {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@@ -25,11 +25,13 @@ type Color =
|
||||
| 'warninglight'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'blue'
|
||||
| 'none';
|
||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
export interface Props<TasProps = unknown>
|
||||
extends AriaAttributes, AutomationTestingProps {
|
||||
extends AriaAttributes,
|
||||
AutomationTestingProps {
|
||||
icon?: ReactNode | ComponentType<unknown>;
|
||||
|
||||
color?: Color;
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { FormikHelpers } from 'formik';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Alert } from '@@/Alert';
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
|
||||
import { useGroup } from '../queries/useGroup';
|
||||
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
||||
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
|
||||
import { queryKeys } from '../queries/query-keys';
|
||||
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
||||
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
||||
|
||||
export function EditGroupView() {
|
||||
const groupId = useIdParam();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const groupQuery = useGroup(groupId);
|
||||
const isUnassignedGroup = groupId === 1;
|
||||
const updateMutation = useUpdateGroupMutation();
|
||||
const deleteMutation = useDeleteEnvironmentGroupMutation();
|
||||
|
||||
const initialValues: GroupFormValues = useMemo(
|
||||
() => ({
|
||||
@@ -37,7 +44,15 @@ export function EditGroupView() {
|
||||
{ label: 'Groups', link: 'portainer.groups' },
|
||||
{ label: groupQuery.data?.Name ?? 'Edit group' },
|
||||
]}
|
||||
>
|
||||
<DeleteButton
|
||||
disabled={isUnassignedGroup || !groupQuery.data}
|
||||
confirmMessage="Are you sure you want to delete this environment group? Environments within it will become unassigned."
|
||||
onConfirmed={handleDelete}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
data-cy="delete-environment-group-button"
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
@@ -73,6 +88,13 @@ export function EditGroupView() {
|
||||
</>
|
||||
);
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteMutation.mutateAsync(groupId);
|
||||
notifySuccess('Success', 'Environment group successfully deleted');
|
||||
await router.stateService.go('portainer.groups');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.base(), exact: true });
|
||||
}
|
||||
|
||||
async function handleSubmit(
|
||||
values: GroupFormValues,
|
||||
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { Dice4 } from 'lucide-react';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { TableActions } from './TableActions';
|
||||
|
||||
const tableKey = 'environment-groups';
|
||||
const store = createPersistedStore(tableKey);
|
||||
|
||||
export function EnvironmentGroupsDatatable() {
|
||||
const query = useEnvironmentGroups();
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
columns={columns}
|
||||
isLoading={query.isLoading}
|
||||
dataset={query.data || []}
|
||||
settingsManager={tableState}
|
||||
title="Environment Groups"
|
||||
titleIcon={Dice4}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<TableActions selectedItems={selectedItems} />
|
||||
)}
|
||||
data-cy="environment-groups-datatable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
import { AddButton } from '@@/buttons';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
|
||||
import { useDeleteEnvironmentGroupsMutation } from './useDeleteEnvironmentGroupsMutation';
|
||||
|
||||
export function TableActions({
|
||||
selectedItems,
|
||||
}: {
|
||||
selectedItems: EnvironmentGroup[];
|
||||
}) {
|
||||
const deleteMutation = useDeleteEnvironmentGroupsMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteButton
|
||||
disabled={selectedItems.length === 0}
|
||||
confirmMessage="Are you sure you want to remove the selected environment group(s)?"
|
||||
onConfirmed={handleRemove}
|
||||
data-cy="remove-environment-groups-button"
|
||||
/>
|
||||
|
||||
<AddButton data-cy="add-environment-group-button">Add group</AddButton>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleRemove() {
|
||||
const ids = selectedItems.map((item) => item.Id);
|
||||
deleteMutation.mutate(ids, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Environment Group(s) removed');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { buildNameColumn } from '@@/datatables/buildNameColumn';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
|
||||
const columnHelper = createColumnHelper<EnvironmentGroup>();
|
||||
|
||||
export const columns = [
|
||||
buildNameColumn<EnvironmentGroup>('Name', '.group', 'environment-group-name'),
|
||||
columnHelper.display({
|
||||
header: 'Actions',
|
||||
cell: ActionsCell,
|
||||
}),
|
||||
];
|
||||
|
||||
function ActionsCell({
|
||||
row: { original: item },
|
||||
}: CellContext<EnvironmentGroup, unknown>) {
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
props={{
|
||||
to: '.group.access',
|
||||
params: { id: item.Id },
|
||||
}}
|
||||
color="link"
|
||||
icon={Users}
|
||||
data-cy={`manage-access-button_${item.Name}`}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
import { buildUrl } from '../../queries/build-url';
|
||||
import { queryKeys } from '../../queries/query-keys';
|
||||
|
||||
export function useDeleteEnvironmentGroupsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteEnvironmentGroups,
|
||||
...withError('Failed to delete environment groups'),
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteEnvironmentGroups(
|
||||
environmentGroupIds: Array<EnvironmentGroup['Id']>
|
||||
) {
|
||||
return promiseSequence(
|
||||
environmentGroupIds.map(
|
||||
(environmentGroupId) => () => deleteEnvironmentGroup(environmentGroupId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteEnvironmentGroup(id: EnvironmentGroup['Id']) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to delete environment group');
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { LayoutGrid, Columns3Cog, Users } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { Tag } from '@/portainer/tags/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
import { EnvironmentTypeBreakdown } from '../../components/EnvironmentTypeBreakdown';
|
||||
import { PlatformBadge } from '../../components/PlatformBadge';
|
||||
import { isUngoverned } from '../../utils/getPlatformLabel';
|
||||
|
||||
interface Props {
|
||||
group: EnvironmentGroup;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export function EnvironmentGroupRow({ group, tags }: Props) {
|
||||
const router = useRouter();
|
||||
const ungoverned = isUngoverned(group);
|
||||
|
||||
function handleRowClick() {
|
||||
if (!ungoverned) {
|
||||
router.stateService.go('portainer.groups.group', { id: group.Id });
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!ungoverned && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex items-center gap-5 border-0 border-t border-solid border-gray-4 px-5 py-4 transition-colors',
|
||||
ungoverned
|
||||
? 'cursor-default bg-gray-2 th-highcontrast:bg-gray-10 th-dark:bg-gray-10'
|
||||
: 'cursor-pointer hover:bg-gray-2 th-dark:hover:bg-gray-9',
|
||||
'th-dark:border-gray-8'
|
||||
)}
|
||||
data-cy={`environment-group-row-${group.Name}`}
|
||||
onClick={ungoverned ? undefined : handleRowClick}
|
||||
onKeyDown={ungoverned ? undefined : handleKeyDown}
|
||||
role={ungoverned ? undefined : 'button'}
|
||||
tabIndex={ungoverned ? undefined : 0}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl',
|
||||
ungoverned
|
||||
? 'bg-warning-7 th-dark:bg-warning-10'
|
||||
: 'bg-blue-3 th-dark:bg-blue-9'
|
||||
)}
|
||||
>
|
||||
{ungoverned ? (
|
||||
<Columns3Cog
|
||||
className={clsx(
|
||||
'h-6 w-6',
|
||||
'!text-warning-4 th-dark:text-warning-3'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<LayoutGrid
|
||||
className={clsx('h-6 w-6', 'text-blue-9 th-dark:text-blue-3')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Name + Tag badges */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-bold',
|
||||
ungoverned
|
||||
? 'text-warning-7 th-dark:text-warning-3'
|
||||
: 'text-gray-8 th-highcontrast:text-white th-dark:text-gray-2'
|
||||
)}
|
||||
data-cy={`environment-group-name-${group.Name}`}
|
||||
>
|
||||
{group.Name}
|
||||
</span>
|
||||
{!ungoverned && <PlatformBadge group={group} />}
|
||||
{!ungoverned &&
|
||||
group.TagIds?.map((tagId) => {
|
||||
const tag = tags?.find((t) => t.ID === tagId);
|
||||
return (
|
||||
<Badge key={tagId} type="info" className="text-xs">
|
||||
{tag?.Name ?? `Tag ${tagId}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-1 flex items-center gap-4 text-xs',
|
||||
ungoverned
|
||||
? 'text-warning-7 th-dark:text-warning-4'
|
||||
: 'text-gray-7 th-highcontrast:text-white group-hover:th-highcontrast:text-gray-11 th-dark:text-gray-5'
|
||||
)}
|
||||
>
|
||||
<EnvironmentTypeBreakdown group={group} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!ungoverned && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{
|
||||
to: 'portainer.groups.group.access',
|
||||
params: { id: group.Id },
|
||||
}}
|
||||
color="link"
|
||||
icon={Users}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
data-cy={`manage-access-button_${group.Name}`}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { vi } from 'vitest';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { server } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
|
||||
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable';
|
||||
|
||||
vi.mock('@@/Link', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<{ to: string; params?: Record<string, unknown> }>) => (
|
||||
<span role="button" tabIndex={0} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
function buildGroup(
|
||||
overrides: Partial<EnvironmentGroup> & { Id: number; Name: string }
|
||||
): EnvironmentGroup {
|
||||
return {
|
||||
Description: '',
|
||||
TagIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockGroups: Array<EnvironmentGroup> = [
|
||||
buildGroup({
|
||||
Id: 2,
|
||||
Name: 'Production Kubernetes',
|
||||
Description: 'Production k8s cluster',
|
||||
TagIds: [1, 2],
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 3,
|
||||
Name: 'Development Kubernetes',
|
||||
TagIds: [1],
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 4,
|
||||
Name: 'Docker Hosts',
|
||||
TagIds: [3],
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 5,
|
||||
Name: 'Staging Mixed',
|
||||
TagIds: [2],
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 6,
|
||||
Name: 'Edge Fleet',
|
||||
}),
|
||||
];
|
||||
|
||||
function searchInput() {
|
||||
return screen.getByTestId('environment-groups-list-header-search');
|
||||
}
|
||||
function sortByName() {
|
||||
return screen.getByTestId(
|
||||
'environment-groups-list-header-sort-sort-by-name-button'
|
||||
);
|
||||
}
|
||||
function nextPageBtn() {
|
||||
return screen.getByTitle('Next page');
|
||||
}
|
||||
function prevPageBtn() {
|
||||
return screen.getByTitle('Previous page');
|
||||
}
|
||||
|
||||
async function waitForLoaded() {
|
||||
await waitFor(() => {
|
||||
const rows = screen.queryAllByTestId(/^environment-group-row-/);
|
||||
const empty = screen.queryByText(
|
||||
/No environment groups found|No groups match/
|
||||
);
|
||||
if (rows.length === 0 && !empty) throw new Error('Still loading');
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups)),
|
||||
http.get('/api/tags', () =>
|
||||
HttpResponse.json([
|
||||
{ ID: 1, Name: 'production' },
|
||||
{ ID: 2, Name: 'staging' },
|
||||
{ ID: 3, Name: 'docker' },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
function renderEmptyTable() {
|
||||
server.use(http.get('/api/endpoint_groups', () => HttpResponse.json([])));
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('EnvironmentGroupsTable', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should show loading state initially', () => {
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
return HttpResponse.json(mockGroups);
|
||||
})
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
// Header renders immediately; group rows are not yet visible
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
'environment-groups-list-header-sort-sort-by-name-button'
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByTestId(/^environment-group-row-/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all groups after loading', async () => {
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
mockGroups.forEach((group) => {
|
||||
expect(
|
||||
screen.getByTestId(`environment-group-row-${group.Name}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when no groups exist', async () => {
|
||||
renderEmptyTable();
|
||||
|
||||
expect(
|
||||
await screen.findByText('No environment groups found')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter groups by name', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
await user.type(searchInput(), 'Production');
|
||||
|
||||
// SearchBar debounces input; wait for the filter to apply
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('environment-group-row-Docker Hosts')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('environment-group-row-Production Kubernetes')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('should filter case-insensitively', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
await user.type(searchInput(), 'docker');
|
||||
|
||||
// SearchBar debounces input; wait for the filter to apply
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('environment-group-row-Docker Hosts')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show no results message when filter matches nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
await user.type(searchInput(), 'nonexistent');
|
||||
|
||||
// SearchBar debounces input; wait for the empty state to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No groups match your search')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', () => {
|
||||
it('should sort by name ascending by default', async () => {
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows[0]).toHaveAttribute(
|
||||
'data-cy',
|
||||
'environment-group-row-Development Kubernetes'
|
||||
);
|
||||
expect(rows[1]).toHaveAttribute(
|
||||
'data-cy',
|
||||
'environment-group-row-Docker Hosts'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle sort direction when clicking the active sort', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Click name again to reverse
|
||||
await user.click(sortByName());
|
||||
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows[0]).toHaveAttribute(
|
||||
'data-cy',
|
||||
'environment-group-row-Staging Mixed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should show pagination info when items exist', async () => {
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// SortableListPagerInfo always shows when totalCount > 0
|
||||
expect(screen.getByRole('combobox')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show pagination when items exceed page size', async () => {
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
expect(nextPageBtn()).toBeVisible();
|
||||
// Should show 10 items on first page
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should navigate to next page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
await user.click(nextPageBtn());
|
||||
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should reset to page 1 when filtering', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Go to page 2
|
||||
await user.click(nextPageBtn());
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(5);
|
||||
|
||||
// Type a filter - safePage clamps to page 1
|
||||
await user.type(searchInput(), 'Group 0');
|
||||
|
||||
// Should show filtered results from page 1
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to page 1 when changing sort', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Go to page 2
|
||||
await user.click(nextPageBtn());
|
||||
|
||||
// Toggle Name sort direction
|
||||
await user.click(sortByName());
|
||||
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sort header', () => {
|
||||
it('should render the name sort button', async () => {
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
expect(sortByName()).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the filter input', async () => {
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
expect(searchInput()).toBeVisible();
|
||||
expect(searchInput()).toHaveAttribute('placeholder', 'Filter groups...');
|
||||
});
|
||||
|
||||
it('should render the add group button in the page header (not in the table)', async () => {
|
||||
// The Add Group button lives in ListView's PageHeader, not in EnvironmentGroupsTable.
|
||||
// This test confirms it is absent from the table component itself.
|
||||
renderTable();
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('add-environment-group-button')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination - Advanced', () => {
|
||||
it('should navigate to previous page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Go to page 2
|
||||
await user.click(nextPageBtn());
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(5);
|
||||
|
||||
// Go back to page 1
|
||||
await user.click(prevPageBtn());
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should disable next button on last page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
const next = nextPageBtn();
|
||||
expect(next).not.toBeDisabled();
|
||||
|
||||
// Navigate to the last page (page 2 of 2 with 15 items at 10/page)
|
||||
await user.click(next);
|
||||
|
||||
expect(next).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should change items per page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Initial page should have 10 items
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||
|
||||
// Change to 25 items per page
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.selectOptions(select, '25');
|
||||
|
||||
// Should now show 25 items
|
||||
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(25);
|
||||
});
|
||||
|
||||
it('should navigate to specific page number', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 30 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Click page 3
|
||||
await user.click(screen.getByRole('button', { name: '3' }));
|
||||
|
||||
// Should show items from page 3
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should reset to page 1 after filtering results', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: i % 2 === 0 ? `ProductionGroup ${i}` : `Group ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Go to page 2
|
||||
await user.click(nextPageBtn());
|
||||
|
||||
// Filter - safePage clamps back to page 1
|
||||
await user.type(searchInput(), 'Production');
|
||||
|
||||
// Should show filtered results from page 1
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group Row Features', () => {
|
||||
it('should display ungoverned group', async () => {
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () =>
|
||||
HttpResponse.json([
|
||||
buildGroup({
|
||||
Id: 1,
|
||||
Name: 'Unassigned',
|
||||
}),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('environment-group-row-Unassigned')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combinations - Sorting + Pagination + Filtering', () => {
|
||||
it('should maintain sort order after pagination', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
const firstPageOrder = screen
|
||||
.getAllByTestId(/^environment-group-row-/)[0]
|
||||
.getAttribute('data-cy');
|
||||
|
||||
// Go to next page
|
||||
await user.click(nextPageBtn());
|
||||
|
||||
// Go back to first page
|
||||
await user.click(prevPageBtn());
|
||||
|
||||
// Order should be the same
|
||||
const returnedFirstRow = screen
|
||||
.getAllByTestId(/^environment-group-row-/)[0]
|
||||
.getAttribute('data-cy');
|
||||
expect(firstPageOrder).toBe(returnedFirstRow);
|
||||
});
|
||||
|
||||
it('should apply filter and paginate together', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const manyGroups = Array.from({ length: 20 }, (_, i) =>
|
||||
buildGroup({
|
||||
Id: i + 1,
|
||||
Name: i % 2 === 0 ? `ProdGroup ${i}` : `DevGroup ${i}`,
|
||||
})
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||
);
|
||||
render(<Wrapped />);
|
||||
|
||||
await waitForLoaded();
|
||||
|
||||
// Filter by "Prod"
|
||||
await user.type(searchInput(), 'Prod');
|
||||
|
||||
// SearchBar debounces input; wait for filtered rows to settle
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
rows.forEach((row) => {
|
||||
expect(row).toHaveAttribute(
|
||||
'data-cy',
|
||||
expect.stringContaining('ProdGroup')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
|
||||
import {
|
||||
SortableGroup,
|
||||
SortableList,
|
||||
SortOption,
|
||||
} from '@@/SortableList/SortableList';
|
||||
import { useSortableListState } from '@@/SortableList/sortable-list.store';
|
||||
|
||||
import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups';
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
import { isUngoverned } from '../../utils/getPlatformLabel';
|
||||
|
||||
import { EnvironmentGroupRow } from './EnvironmentGroupRow';
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [{ key: 'Name', label: 'Name' }];
|
||||
|
||||
export function EnvironmentGroupsTable() {
|
||||
const groupsQuery = useEnvironmentGroups();
|
||||
const tagsQuery = useTags();
|
||||
const tableState = useSortableListState('environment_groups', 'Name');
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const data = groupsQuery.data ?? [];
|
||||
const governed = data.filter((g) => !isUngoverned(g));
|
||||
const ungoverned = data.filter((g) => isUngoverned(g));
|
||||
const sortedGoverned = [...governed].sort((a, b) => {
|
||||
const cmp = a.Name.localeCompare(b.Name);
|
||||
return tableState.sortBy?.desc ? -cmp : cmp;
|
||||
});
|
||||
return [...sortedGoverned, ...ungoverned];
|
||||
}, [groupsQuery.data, tableState.sortBy?.desc]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = tableState.search.toLowerCase();
|
||||
if (!term) return sorted;
|
||||
return sorted.filter(
|
||||
(g) =>
|
||||
g.Name.toLowerCase().includes(term) ||
|
||||
g.Description?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [sorted, tableState.search]);
|
||||
|
||||
const pageItems = useMemo(() => {
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(filtered.length / tableState.pageSize)
|
||||
);
|
||||
const safePage = Math.min(tableState.page, totalPages - 1);
|
||||
const start = safePage * tableState.pageSize;
|
||||
return filtered.slice(start, start + tableState.pageSize);
|
||||
}, [filtered, tableState.page, tableState.pageSize]);
|
||||
|
||||
const groups = useMemo(() => buildGroups(pageItems), [pageItems]);
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
tableState={tableState}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
groups={groups}
|
||||
totalCount={filtered.length}
|
||||
renderItem={(group) => (
|
||||
<EnvironmentGroupRow group={group} tags={tagsQuery.data} />
|
||||
)}
|
||||
getItemKey={(group) => group.Id}
|
||||
isLoading={groupsQuery.isLoading}
|
||||
searchPlaceholder="Filter groups..."
|
||||
emptyMessage={
|
||||
tableState.search
|
||||
? 'No groups match your search'
|
||||
: 'No environment groups found'
|
||||
}
|
||||
data-cy="environment-groups-list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildGroups(
|
||||
items: EnvironmentGroup[]
|
||||
): SortableGroup<EnvironmentGroup>[] {
|
||||
const governed = items.filter((g) => !isUngoverned(g));
|
||||
const ungoverned = items.filter((g) => isUngoverned(g));
|
||||
const result: SortableGroup<EnvironmentGroup>[] = [];
|
||||
if (governed.length) {
|
||||
result.push({ key: 'governed', label: 'Groups', items: governed });
|
||||
}
|
||||
if (ungoverned.length) {
|
||||
result.push({ key: 'ungoverned', label: 'Ungoverned', items: ungoverned });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { ReactStateDeclaration } from '@uirouter/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
|
||||
|
||||
function buildGroup(
|
||||
overrides: Partial<EnvironmentGroup> & { Id: number; Name: string }
|
||||
): EnvironmentGroup {
|
||||
return {
|
||||
Description: '',
|
||||
TagIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sampleGroups: Array<EnvironmentGroup> = [
|
||||
buildGroup({
|
||||
Id: 2,
|
||||
Name: 'Production - AWS',
|
||||
Description: 'Production workloads on AWS EKS',
|
||||
Total: 12,
|
||||
TypeInfo: { Docker: 0, Kubernetes: 12, Podman: 0, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 3,
|
||||
Name: 'Staging - Azure',
|
||||
Description: 'Pre-production staging on Azure AKS',
|
||||
Total: 5,
|
||||
TypeInfo: { Docker: 0, Kubernetes: 5, Podman: 0, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 4,
|
||||
Name: 'CI/CD Runners',
|
||||
Description: 'Containerised build and test agents',
|
||||
Total: 8,
|
||||
TypeInfo: { Docker: 8, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 5,
|
||||
Name: 'Dev Workstations',
|
||||
Description: 'Developer local Docker environments',
|
||||
Total: 6,
|
||||
TypeInfo: { Docker: 6, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 6,
|
||||
Name: 'Edge Devices',
|
||||
Description: 'Edge computing nodes across regional sites',
|
||||
Total: 14,
|
||||
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 14, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 7,
|
||||
Name: 'IoT Gateway Fleet',
|
||||
Description: 'Podman-based IoT gateway nodes',
|
||||
Total: 9,
|
||||
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 9, Mixed: false },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 8,
|
||||
Name: 'Platform Services',
|
||||
Description: 'Mixed workloads — Kubernetes, Docker, and Podman',
|
||||
Total: 11,
|
||||
TypeInfo: { Docker: 4, Kubernetes: 5, Podman: 2, Mixed: true },
|
||||
}),
|
||||
buildGroup({
|
||||
Id: 1,
|
||||
Name: 'Unassigned',
|
||||
Description: '',
|
||||
Total: 3,
|
||||
TypeInfo: { Docker: 1, Kubernetes: 1, Podman: 1, Mixed: true },
|
||||
}),
|
||||
];
|
||||
|
||||
const stateConfig: Array<ReactStateDeclaration> = [
|
||||
{ name: 'portainer.groups', component: () => null },
|
||||
{ name: 'portainer.groups.group', component: () => null },
|
||||
{ name: 'portainer.groups.new', component: () => null },
|
||||
];
|
||||
|
||||
const WrappedPage = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(EnvironmentGroupsTable), {
|
||||
route: 'portainer.groups',
|
||||
stateConfig,
|
||||
})
|
||||
);
|
||||
|
||||
const meta: Meta<typeof WrappedPage> = {
|
||||
component: WrappedPage,
|
||||
title: 'Pages/Environment Groups',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WrappedPage>;
|
||||
|
||||
export const Default: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('/api/endpoint_groups', () => HttpResponse.json(sampleGroups)),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get('/api/endpoint_groups', () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyGroups: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('/api/endpoint_groups', () => {
|
||||
const platforms: Array<Pick<EnvironmentGroup, 'TypeInfo' | 'Total'>> =
|
||||
[
|
||||
{
|
||||
TypeInfo: { Docker: 8, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||
Total: 8,
|
||||
},
|
||||
{
|
||||
TypeInfo: { Docker: 0, Kubernetes: 6, Podman: 0, Mixed: false },
|
||||
Total: 6,
|
||||
},
|
||||
{
|
||||
TypeInfo: { Docker: 3, Kubernetes: 4, Podman: 1, Mixed: true },
|
||||
Total: 8,
|
||||
},
|
||||
{
|
||||
TypeInfo: { Docker: 12, Kubernetes: 0, Podman: 2, Mixed: true },
|
||||
Total: 14,
|
||||
},
|
||||
{
|
||||
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 5, Mixed: false },
|
||||
Total: 5,
|
||||
},
|
||||
];
|
||||
const names = [
|
||||
'Production',
|
||||
'Staging',
|
||||
'Development',
|
||||
'Testing',
|
||||
'Lab',
|
||||
'Edge',
|
||||
'Data',
|
||||
'Monitoring',
|
||||
'Security',
|
||||
'Infra',
|
||||
];
|
||||
const regions = ['AWS', 'Azure', 'GCP', 'On-Premise', 'Hybrid'];
|
||||
|
||||
const groups = Array.from({ length: 30 }, (_, i) => {
|
||||
const p = platforms[i % platforms.length];
|
||||
return buildGroup({
|
||||
Id: i + 2,
|
||||
Name: `${names[i % names.length]} - ${
|
||||
regions[i % regions.length]
|
||||
}`,
|
||||
Description: `Auto-generated group ${i + 1}`,
|
||||
Total: p.Total,
|
||||
TypeInfo: p.TypeInfo,
|
||||
});
|
||||
});
|
||||
|
||||
return HttpResponse.json([
|
||||
...groups,
|
||||
buildGroup({
|
||||
Id: 1,
|
||||
Name: 'Unassigned',
|
||||
Total: 3,
|
||||
TypeInfo: { Docker: 2, Kubernetes: 1, Podman: 0, Mixed: true },
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,35 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
|
||||
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
|
||||
|
||||
export function ListView() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Environment Groups"
|
||||
breadcrumbs="Environment group management"
|
||||
reload
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
onClick={() => router.stateService.go('portainer.groups.new')}
|
||||
icon={Plus}
|
||||
color="primary"
|
||||
size="small"
|
||||
data-cy="add-environment-group-button"
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<EnvironmentGroupsDatatable />
|
||||
<div className="mx-5">
|
||||
<EnvironmentGroupsTable />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
import { EnvironmentTypeBreakdown } from './EnvironmentTypeBreakdown';
|
||||
|
||||
function buildGroup(
|
||||
overrides: Partial<EnvironmentGroup> = {}
|
||||
): EnvironmentGroup {
|
||||
return {
|
||||
Id: 2,
|
||||
Name: 'test-group',
|
||||
Description: '',
|
||||
TagIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EnvironmentTypeBreakdown', () => {
|
||||
it('renders nothing when Total is 0', () => {
|
||||
const { container } = render(
|
||||
<EnvironmentTypeBreakdown group={buildGroup({ Total: 0 })} />
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when Total is undefined', () => {
|
||||
const { container } = render(
|
||||
<EnvironmentTypeBreakdown group={buildGroup()} />
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders singular environment count when TypeInfo is absent', () => {
|
||||
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 1 })} />);
|
||||
expect(screen.getByText('1 Environment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders plural environment count when TypeInfo is absent', () => {
|
||||
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 5 })} />);
|
||||
expect(screen.getByText('5 Environments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies data-cy attribute using group name', () => {
|
||||
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 3 })} />);
|
||||
expect(
|
||||
screen.getByTestId('environment-group-size_test-group')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Kubernetes logo and count', () => {
|
||||
render(
|
||||
<EnvironmentTypeBreakdown
|
||||
group={buildGroup({
|
||||
Total: 2,
|
||||
TypeInfo: { Kubernetes: 2, Docker: 0, Podman: 0, Mixed: false },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByAltText('Kubernetes')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Docker')).not.toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Podman')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Docker logo and count', () => {
|
||||
render(
|
||||
<EnvironmentTypeBreakdown
|
||||
group={buildGroup({
|
||||
Total: 3,
|
||||
TypeInfo: { Kubernetes: 0, Docker: 3, Podman: 0, Mixed: false },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByAltText('Docker')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Kubernetes')).not.toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Podman')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Podman logo and count', () => {
|
||||
render(
|
||||
<EnvironmentTypeBreakdown
|
||||
group={buildGroup({
|
||||
Total: 1,
|
||||
TypeInfo: { Kubernetes: 0, Docker: 0, Podman: 1, Mixed: true },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByAltText('Podman')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Kubernetes')).not.toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Docker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all three logos when all platform types are present', () => {
|
||||
render(
|
||||
<EnvironmentTypeBreakdown
|
||||
group={buildGroup({
|
||||
Total: 6,
|
||||
TypeInfo: { Kubernetes: 2, Docker: 3, Podman: 1, Mixed: true },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByAltText('Kubernetes')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Docker')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Podman')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import KubernetesLogo from '@/assets/ico/vendor/kubernetes.svg';
|
||||
import DockerLogo from '@/assets/ico/vendor/docker.svg';
|
||||
import PodmanLogo from '@/assets/ico/vendor/podman.svg';
|
||||
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
interface Props {
|
||||
group: EnvironmentGroup;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EnvironmentTypeBreakdown({ group, className }: Props) {
|
||||
if (!group.Total || group.Total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!group.TypeInfo) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
data-cy={`environment-group-size_${group.Name}`}
|
||||
>
|
||||
{group.Total} {group.Total === 1 ? 'Environment' : 'Environments'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('flex items-center gap-3', className)}
|
||||
data-cy={`environment-group-size_${group.Name}`}
|
||||
>
|
||||
{group.TypeInfo.Kubernetes > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<img src={KubernetesLogo} alt="Kubernetes" className="h-4 w-4" />
|
||||
<span>
|
||||
<b>{group.TypeInfo.Kubernetes}</b> Kubernetes
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{group.TypeInfo.Docker > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<img src={DockerLogo} alt="Docker" className="h-4 w-4" />
|
||||
<span>
|
||||
<b>{group.TypeInfo.Docker}</b> Docker
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{group.TypeInfo.Podman > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<img src={PodmanLogo} alt="Podman" className="h-4 w-4" />
|
||||
<span>
|
||||
<b>{group.TypeInfo.Podman}</b> Podman
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EnvironmentGroup } from '../types';
|
||||
import { getPlatformLabel } from '../utils/getPlatformLabel';
|
||||
|
||||
const colorByLabel: Record<string, string> = {
|
||||
Docker: 'bg-green-2',
|
||||
Kubernetes: 'bg-blue-2',
|
||||
Podman: 'bg-orange-2',
|
||||
Mixed: 'bg-purple-2',
|
||||
Empty: 'bg-gray-2',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
group: EnvironmentGroup;
|
||||
}
|
||||
|
||||
export function PlatformBadge({ group }: Props) {
|
||||
const platformLabel = getPlatformLabel(group);
|
||||
const colorClass = colorByLabel[platformLabel] ?? 'bg-gray-2';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`flex w-fit items-center rounded-xl ${colorClass} px-2 py-px text-xs font-bold text-gray-9 th-highcontrast:bg-gray-8 th-highcontrast:text-white th-dark:bg-gray-8 th-dark:text-gray-3`}
|
||||
data-cy={`environment-group-platform_${group.Name}`}
|
||||
>
|
||||
{platformLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EnvironmentGroupId } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export function useDeleteEnvironmentGroupMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (id: EnvironmentGroupId) => deleteEnvironmentGroup(id),
|
||||
...withError('Failed to delete environment group'),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteEnvironmentGroup(id: EnvironmentGroupId) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete environment group');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
@@ -11,6 +12,7 @@ export function useEnvironmentGroups() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.base(),
|
||||
queryFn: () => getEnvironmentGroups(),
|
||||
...withError('Unable to retrieve environment groups'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +21,6 @@ async function getEnvironmentGroups() {
|
||||
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl());
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to get access tokens');
|
||||
throw parseAxiosError(e, 'Unable to retrieve environment groups');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
EnvironmentGroupId,
|
||||
} from '../types';
|
||||
|
||||
export interface EnvironmentGroupTypeInfo {
|
||||
Docker: number;
|
||||
Kubernetes: number;
|
||||
Podman: number;
|
||||
Mixed: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentGroup {
|
||||
// Environment(Endpoint) group Identifier
|
||||
Id: EnvironmentGroupId;
|
||||
@@ -17,4 +24,6 @@ export interface EnvironmentGroup {
|
||||
TagIds: TagId[];
|
||||
UserAccessPolicies?: UserAccessPolicies;
|
||||
TeamAccessPolicies?: TeamAccessPolicies;
|
||||
Total?: number;
|
||||
TypeInfo?: EnvironmentGroupTypeInfo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
export function getPlatformLabel(group: EnvironmentGroup): string {
|
||||
if (!group.Total || group.Total === 0) {
|
||||
return 'Empty';
|
||||
}
|
||||
|
||||
const typeInfo = group.TypeInfo;
|
||||
if (!typeInfo) {
|
||||
return 'Mixed';
|
||||
}
|
||||
|
||||
if (!typeInfo.Mixed) {
|
||||
if (typeInfo.Docker > 0) return 'Docker';
|
||||
if (typeInfo.Kubernetes > 0) return 'Kubernetes';
|
||||
if (typeInfo.Podman > 0) return 'Podman';
|
||||
return 'Empty';
|
||||
}
|
||||
|
||||
return 'Mixed';
|
||||
}
|
||||
|
||||
export function isUngoverned(group: EnvironmentGroup): boolean {
|
||||
return group.Id === 1;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ module.exports = {
|
||||
current: 'currentColor',
|
||||
inherit: 'inherit',
|
||||
...colors,
|
||||
'group-accent': colors.violet,
|
||||
|
||||
'legacy-grey-3': 'var(--grey-3)',
|
||||
'legacy-blue-2': 'var(--blue-2)',
|
||||
|
||||
Reference in New Issue
Block a user