mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:40:13 +00:00
feat(gitops): introduce sources list view [BE-12902] (#2550)
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Children,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useContext,
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type MenuCtxType = {
|
||||||
|
isOpen: boolean;
|
||||||
|
setOpen: (v: boolean) => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement>;
|
||||||
|
label: string;
|
||||||
|
setLabel: (v: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuCtx = createContext<MenuCtxType | null>(null);
|
||||||
|
|
||||||
|
export function Menu({ children }: { children?: ReactNode }) {
|
||||||
|
const [isOpen, setOpen] = useState(false);
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleDocDown(e: MouseEvent) {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
menuRef.current &&
|
||||||
|
target &&
|
||||||
|
!menuRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleDocDown);
|
||||||
|
return () => document.removeEventListener('mousedown', handleDocDown);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
|
||||||
|
<div ref={menuRef}>{children}</div>
|
||||||
|
</MenuCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuButton({
|
||||||
|
children,
|
||||||
|
onClick: externalOnClick,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) {
|
||||||
|
const ctx = useContext(MenuCtx);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstText = Children.toArray(children).find(
|
||||||
|
(c) => typeof c === 'string'
|
||||||
|
);
|
||||||
|
if (firstText) ctx?.setLabel(firstText as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
externalOnClick?.();
|
||||||
|
ctx?.setOpen(!ctx.isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuList({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ctx = useContext(MenuCtx);
|
||||||
|
if (!ctx?.isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div role="menu" aria-label={ctx.label || undefined} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuItem({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ctx = useContext(MenuCtx);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
onSelect?.();
|
||||||
|
ctx?.setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
|
||||||
|
<div role="menuitem" onClick={handleClick} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package workflows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
"github.com/portainer/portainer/api/set"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchWorkflows returns all GitOps workflows visible to the given user.
|
||||||
|
func FetchWorkflows(
|
||||||
|
ctx context.Context,
|
||||||
|
dataStore dataservices.DataStore,
|
||||||
|
gitService portainer.GitService,
|
||||||
|
k8sFactory *cli.ClientFactory,
|
||||||
|
sc *security.RestrictedRequestContext,
|
||||||
|
endpointIDSet set.Set[portainer.EndpointID],
|
||||||
|
) ([]Workflow, error) {
|
||||||
|
var entries []portainer.Stack
|
||||||
|
var endpointMap map[portainer.EndpointID]portainer.Endpoint
|
||||||
|
|
||||||
|
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||||
|
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointMap, err = buildEndpointMap(tx, stacks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range stacks {
|
||||||
|
s := stacks[i]
|
||||||
|
|
||||||
|
if ep, ok := endpointMap[s.EndpointID]; ok && !EndpointMatchesStackType(ep, s.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err = filterK8SStacks(entries, endpointMap, k8sFactory, accessMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]Workflow, 0, len(entries))
|
||||||
|
for _, s := range entries {
|
||||||
|
source, artifact := computePhases(ctx, gitService, s.GitConfig)
|
||||||
|
items = append(items, MapStackToWorkflow(s, s.GitConfig, source, artifact))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact WorkflowPhaseStatus) {
|
||||||
|
if gitSvc == nil || cfg == nil {
|
||||||
|
return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password := gitCredentials(cfg)
|
||||||
|
return ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath,
|
||||||
|
func(ctx context.Context) ([]string, error) {
|
||||||
|
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
|
||||||
|
},
|
||||||
|
func(ctx context.Context, exts []string) ([]string, error) {
|
||||||
|
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
|
||||||
|
if cfg.Authentication != nil {
|
||||||
|
return cfg.Authentication.Username, cfg.Authentication.Password
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package workflows
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
@@ -15,7 +18,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
|
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
|
||||||
switch stackType {
|
switch stackType {
|
||||||
case portainer.DockerSwarmStack:
|
case portainer.DockerSwarmStack:
|
||||||
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
|
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
|
||||||
@@ -47,11 +50,6 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type endpointAccess struct {
|
|
||||||
isKubeAdmin bool
|
|
||||||
nonAdminNamespaces []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterDockerStacksByAccess filters stacks to only those the current user can access.
|
// filterDockerStacksByAccess filters stacks to only those the current user can access.
|
||||||
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
|
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
|
||||||
if sc.IsAdmin {
|
if sc.IsAdmin {
|
||||||
@@ -102,6 +100,11 @@ func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedReq
|
|||||||
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
|
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type endpointAccess struct {
|
||||||
|
isKubeAdmin bool
|
||||||
|
nonAdminNamespaces []string
|
||||||
|
}
|
||||||
|
|
||||||
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
|
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
|
||||||
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
|
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
|
||||||
|
|
||||||
@@ -120,3 +123,56 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookup only if env is kube and either not edge or (edge + not async)
|
||||||
|
func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
|
||||||
|
return endpointutils.IsKubernetesEndpoint(endpoint) &&
|
||||||
|
(!endpointutils.IsEdgeEndpoint(endpoint) ||
|
||||||
|
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
|
||||||
|
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
|
||||||
|
return s.Type == portainer.KubernetesStack
|
||||||
|
})
|
||||||
|
|
||||||
|
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
|
||||||
|
return s.EndpointID
|
||||||
|
})
|
||||||
|
|
||||||
|
for envID, stacks := range groupedByEnvId {
|
||||||
|
ep, ok := endpointMap[envID]
|
||||||
|
if !ok || !ShouldPerformEnvLookup(&ep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
access := accessMap[envID]
|
||||||
|
kcl.SetIsKubeAdmin(access.isKubeAdmin)
|
||||||
|
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
|
||||||
|
|
||||||
|
apps, err := kcl.GetApplications("", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stacks {
|
||||||
|
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
|
||||||
|
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
|
||||||
|
})
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
app := apps[idx]
|
||||||
|
s.Name = app.Name
|
||||||
|
s.Namespace = app.ResourcePool
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package workflows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
kfake "k8s.io/client-go/kubernetes/fake"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, store := datastore.MustNewTestStore(t, false, true)
|
||||||
|
|
||||||
|
user := &portainer.User{
|
||||||
|
ID: 1,
|
||||||
|
Username: "standard",
|
||||||
|
Role: portainer.StandardUserRole,
|
||||||
|
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
||||||
|
}
|
||||||
|
require.NoError(t, store.User().Create(user))
|
||||||
|
|
||||||
|
sc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: false,
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}
|
||||||
|
dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{kubeStack, dockerStack}
|
||||||
|
|
||||||
|
var result []portainer.Stack
|
||||||
|
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
var txErr error
|
||||||
|
result, txErr = filterDockerStacksByAccess(tx, stacks, sc)
|
||||||
|
return txErr
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.Equal(t, "kube-stack", result[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{
|
||||||
|
{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack},
|
||||||
|
{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := filterDockerStacksByAccess(nil, stacks, sc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||||
|
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||||
|
2: {ID: 2, Type: portainer.DockerEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := buildEndpointAccessMap(nil, sc, endpointMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.True(t, result[1].isKubeAdmin)
|
||||||
|
require.Empty(t, result[1].nonAdminNamespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
|
||||||
|
deployment := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "my-app",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{
|
||||||
|
"io.portainer.kubernetes.application.stackid": "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||||
|
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{
|
||||||
|
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||||
|
1: {isKubeAdmin: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "my-app", result[0].Name)
|
||||||
|
assert.Equal(t, "default", result[0].Namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||||
|
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{
|
||||||
|
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||||
|
1: {isKubeAdmin: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
|
||||||
|
deployment := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "my-app",
|
||||||
|
Namespace: "ns1",
|
||||||
|
Labels: map[string]string{
|
||||||
|
"io.portainer.kubernetes.application.stackid": "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||||
|
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{
|
||||||
|
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||||
|
1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "my-app", result[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
ep := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
Type: portainer.KubernetesLocalEnvironment,
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: false,
|
||||||
|
UserID: 1,
|
||||||
|
UserMemberships: []portainer.TeamMembership{
|
||||||
|
{TeamID: 5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := resolveKubeAccess(factory, sc, ep)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, access.isKubeAdmin)
|
||||||
|
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveKubeAccess_NonAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
ep := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
Type: portainer.KubernetesLocalEnvironment,
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: false,
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := resolveKubeAccess(factory, sc, ep)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, access.isKubeAdmin)
|
||||||
|
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fakeKubeClient := kfake.NewSimpleClientset()
|
||||||
|
|
||||||
|
deployment := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "my-app",
|
||||||
|
Namespace: "ns1",
|
||||||
|
Labels: map[string]string{
|
||||||
|
"io.portainer.kubernetes.application.stackid": "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
||||||
|
factory := cli.NewTestClientFactory(1, kcl)
|
||||||
|
|
||||||
|
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
||||||
|
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
stacks := []portainer.Stack{
|
||||||
|
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessMap := map[portainer.EndpointID]endpointAccess{
|
||||||
|
1: {isKubeAdmin: false, nonAdminNamespaces: []string{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, result)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package workflows
|
|||||||
import (
|
import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
|
"github.com/portainer/portainer/api/set"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
|
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
|
||||||
@@ -49,8 +50,9 @@ func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConf
|
|||||||
},
|
},
|
||||||
GitConfig: gitConfig,
|
GitConfig: gitConfig,
|
||||||
Target: Target{
|
Target: Target{
|
||||||
EdgeGroupIDs: es.EdgeGroups,
|
EdgeGroupIDs: es.EdgeGroups,
|
||||||
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
|
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
|
||||||
|
ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints),
|
||||||
},
|
},
|
||||||
CreationDate: es.CreationDate,
|
CreationDate: es.CreationDate,
|
||||||
LastSyncDate: edgeStackLastSyncDate(statuses),
|
LastSyncDate: edgeStackLastSyncDate(statuses),
|
||||||
@@ -112,6 +114,17 @@ func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveEdgeGroupEndpoints(groups []portainer.EdgeGroupID, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) []portainer.EndpointID {
|
||||||
|
seen := set.Set[portainer.EndpointID]{}
|
||||||
|
for _, gid := range groups {
|
||||||
|
for _, epID := range groupEndpoints[gid] {
|
||||||
|
seen.Add(epID)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen.Keys()
|
||||||
|
}
|
||||||
|
|
||||||
func edgeStackTargetStatuses(
|
func edgeStackTargetStatuses(
|
||||||
groups []portainer.EdgeGroupID,
|
groups []portainer.EdgeGroupID,
|
||||||
statuses []portainer.EdgeStackStatusForEnv,
|
statuses []portainer.EdgeStackStatusForEnv,
|
||||||
|
|||||||
@@ -57,10 +57,11 @@ func ParsePlatform(s string) (DeploymentPlatform, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Target struct {
|
type Target struct {
|
||||||
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
|
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
|
||||||
Namespace string `json:"namespace,omitempty"`
|
Namespace string `json:"namespace,omitempty"`
|
||||||
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
|
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
|
||||||
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
|
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
|
||||||
|
ResolvedEndpointIDs []portainer.EndpointID `json:"resolvedEndpointIds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
|
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/http/handler/gitops/sources"
|
||||||
"github.com/portainer/portainer/api/http/handler/gitops/workflows"
|
"github.com/portainer/portainer/api/http/handler/gitops/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,5 +39,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
|||||||
workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory)
|
workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory)
|
||||||
authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler)
|
authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler)
|
||||||
|
|
||||||
|
sourcesHandler := sources.NewHandler(dataStore, gitService, k8sFactory)
|
||||||
|
authenticatedRouter.PathPrefix("/gitops/sources").Handler(sourcesHandler)
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheTTL = 30 * time.Second
|
||||||
|
cacheCleanupInterval = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is the HTTP handler for the GitOps sources API.
|
||||||
|
type Handler struct {
|
||||||
|
*mux.Router
|
||||||
|
dataStore dataservices.DataStore
|
||||||
|
gitService portainer.GitService
|
||||||
|
cache *gocache.Cache
|
||||||
|
k8sFactory *cli.ClientFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
Router: mux.NewRouter(),
|
||||||
|
dataStore: dataStore,
|
||||||
|
gitService: gitService,
|
||||||
|
cache: gocache.New(cacheTTL, cacheCleanupInterval),
|
||||||
|
k8sFactory: k8sFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := h.PathPrefix("/gitops/sources").Subrouter()
|
||||||
|
router.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
|
||||||
|
router.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/http/utils/filters"
|
||||||
|
"github.com/portainer/portainer/api/set"
|
||||||
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id GitOpsSourcesList
|
||||||
|
// @summary List all GitOps sources
|
||||||
|
// @description Returns a deduplicated list of git repositories used across all GitOps workflows.
|
||||||
|
// @description **Access policy**: admin
|
||||||
|
// @tags gitops
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @param search query string false "Search term (matches URL)"
|
||||||
|
// @param sort query string false "Sort field: name | status | type"
|
||||||
|
// @param order query string false "Sort order: asc or desc"
|
||||||
|
// @param start query int false "Pagination start index"
|
||||||
|
// @param limit query int false "Pagination limit (0 = unlimited)"
|
||||||
|
// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown"
|
||||||
|
// @param type query SourceType false "Filter by source type: git | oci | helm"
|
||||||
|
// @success 200 {array} Source
|
||||||
|
// @failure 400 "Invalid status parameter"
|
||||||
|
// @failure 403 "Access denied"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /gitops/sources [get]
|
||||||
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
params := filters.ExtractListModifiersQueryParams(r)
|
||||||
|
|
||||||
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securityContext.IsAdmin {
|
||||||
|
return httperror.Forbidden("Access denied", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := cacheKey(securityContext)
|
||||||
|
|
||||||
|
sources, err := h.getSources(r.Context(), key, securityContext)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve sources", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
|
||||||
|
s, err := ceWorkflows.ParseStatus(status)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Invalid status parameter", err)
|
||||||
|
}
|
||||||
|
sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Status == s })
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceType, _ := request.RetrieveQueryParameter(r, "type", true); sourceType != "" {
|
||||||
|
t, err := parseSourceType(sourceType)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Invalid type parameter", err)
|
||||||
|
}
|
||||||
|
sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Type == t })
|
||||||
|
}
|
||||||
|
|
||||||
|
results := filters.SearchOrderAndPaginate(sources, params, filters.Config[Source]{
|
||||||
|
SearchAccessors: []filters.SearchAccessor[Source]{
|
||||||
|
func(s Source) (string, error) { return s.URL, nil },
|
||||||
|
},
|
||||||
|
SortBindings: []filters.SortBinding[Source]{
|
||||||
|
{Key: "name", Fn: func(a, b Source) int { return strings.Compare(a.Name, b.Name) }},
|
||||||
|
{Key: "status", Fn: func(a, b Source) int { return strings.Compare(string(a.Status), string(b.Status)) }},
|
||||||
|
{Key: "type", Fn: func(a, b Source) int { return strings.Compare(a.Type, b.Type) }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
filters.ApplyFilterResultsHeaders(&w, results)
|
||||||
|
return response.JSON(w, results.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getSources(ctx context.Context, key string, sc *security.RestrictedRequestContext) ([]Source, error) {
|
||||||
|
if cached, ok := h.cache.Get(key); ok {
|
||||||
|
return slices.Clone(cached.([]Source)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.fetchSources(ctx, sc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.cache.Set(key, result, gocache.DefaultExpiration)
|
||||||
|
return slices.Clone(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKey(sc *security.RestrictedRequestContext) string {
|
||||||
|
teamIDs := make([]string, len(sc.UserMemberships))
|
||||||
|
for i, membership := range sc.UserMemberships {
|
||||||
|
teamIDs[i] = strconv.Itoa(int(membership.TeamID))
|
||||||
|
}
|
||||||
|
slices.Sort(teamIDs)
|
||||||
|
|
||||||
|
return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(teamIDs, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) {
|
||||||
|
workflows, err := ceWorkflows.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
byURL := make(map[string][]ceWorkflows.Workflow)
|
||||||
|
for _, wf := range workflows {
|
||||||
|
if wf.GitConfig != nil {
|
||||||
|
byURL[wf.GitConfig.URL] = append(byURL[wf.GitConfig.URL], wf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := make([]Source, 0, len(byURL))
|
||||||
|
for url, wfs := range byURL {
|
||||||
|
statuses := make([]ceWorkflows.Status, 0, len(wfs))
|
||||||
|
var sourceError string
|
||||||
|
var lastSync int64
|
||||||
|
endpointIDs := make(set.Set[portainer.EndpointID])
|
||||||
|
for _, wf := range wfs {
|
||||||
|
statuses = append(statuses, wf.Status.Source.Status)
|
||||||
|
if sourceError == "" && wf.Status.Source.Status == ceWorkflows.StatusError {
|
||||||
|
sourceError = wf.Status.Source.Error
|
||||||
|
}
|
||||||
|
if wf.LastSyncDate > lastSync {
|
||||||
|
lastSync = wf.LastSyncDate
|
||||||
|
}
|
||||||
|
if wf.Target.EndpointID != 0 {
|
||||||
|
endpointIDs.Add(wf.Target.EndpointID)
|
||||||
|
}
|
||||||
|
for _, id := range wf.Target.ResolvedEndpointIDs {
|
||||||
|
endpointIDs.Add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = append(sources, Source{
|
||||||
|
ID: sourceID(url),
|
||||||
|
Name: repoName(url),
|
||||||
|
Type: "git",
|
||||||
|
URL: url,
|
||||||
|
Status: worstCaseStatus(statuses),
|
||||||
|
Error: sourceError,
|
||||||
|
UsedBy: len(wfs),
|
||||||
|
Environments: len(endpointIDs),
|
||||||
|
LastSync: lastSync,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id GitOpsSourcesSummary
|
||||||
|
// @summary Summarize GitOps source status counts
|
||||||
|
// @description Returns a count of sources per status.
|
||||||
|
// @description **Access policy**: admin
|
||||||
|
// @tags gitops
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 {object} ce.StatusSummary
|
||||||
|
// @failure 403 "Access denied"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /gitops/sources/summary [get]
|
||||||
|
func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securityContext.IsAdmin {
|
||||||
|
return httperror.Forbidden("Access denied", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := cacheKey(securityContext)
|
||||||
|
|
||||||
|
sources, err := h.getSources(r.Context(), key, securityContext)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve sources", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := ce.StatusSummary{}
|
||||||
|
for _, s := range sources {
|
||||||
|
switch s.Status {
|
||||||
|
case ce.StatusHealthy:
|
||||||
|
summary.Healthy++
|
||||||
|
case ce.StatusSyncing:
|
||||||
|
summary.Syncing++
|
||||||
|
case ce.StatusError:
|
||||||
|
summary.Error++
|
||||||
|
case ce.StatusPaused:
|
||||||
|
summary.Paused++
|
||||||
|
default:
|
||||||
|
summary.Unknown++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, summary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package sources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
ce "github.com/portainer/portainer/api/gitops/workflows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source represents a unique git repository used as a GitOps source across one or more workflows.
|
||||||
|
type Source struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status ce.Status `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
UsedBy int `json:"usedBy"`
|
||||||
|
Environments int `json:"environments"`
|
||||||
|
LastSync int64 `json:"lastSync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceTypeGit SourceType = "git"
|
||||||
|
SourceTypeHelm SourceType = "helm"
|
||||||
|
SourceTypeOCI SourceType = "oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseSourceType(s string) (string, error) {
|
||||||
|
switch SourceType(s) {
|
||||||
|
case SourceTypeGit, SourceTypeHelm, SourceTypeOCI:
|
||||||
|
return s, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceID(url string) string {
|
||||||
|
h := sha256.Sum256([]byte(url))
|
||||||
|
return hex.EncodeToString(h[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoName extracts the repository name from a URL.
|
||||||
|
// e.g. "https://github.com/org/app-config.git" → "app-config"
|
||||||
|
func repoName(rawURL string) string {
|
||||||
|
base := path.Base(rawURL)
|
||||||
|
return strings.TrimSuffix(base, ".git")
|
||||||
|
}
|
||||||
|
|
||||||
|
func worstCaseStatus(statuses []ce.Status) ce.Status {
|
||||||
|
priority := map[ce.Status]int{
|
||||||
|
ce.StatusError: 4,
|
||||||
|
ce.StatusSyncing: 3,
|
||||||
|
ce.StatusPaused: 2,
|
||||||
|
ce.StatusHealthy: 1,
|
||||||
|
ce.StatusUnknown: 0,
|
||||||
|
}
|
||||||
|
worst := ce.StatusUnknown
|
||||||
|
for _, s := range statuses {
|
||||||
|
if priority[s] > priority[worst] {
|
||||||
|
worst = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
@@ -5,17 +5,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
|
||||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
kfake "k8s.io/client-go/kubernetes/fake"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -85,274 +78,3 @@ func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) {
|
|||||||
require.Len(t, items, 1)
|
require.Len(t, items, 1)
|
||||||
assert.Equal(t, stackName, items[0].Name)
|
assert.Equal(t, stackName, items[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
_, store := datastore.MustNewTestStore(t, false, true)
|
|
||||||
|
|
||||||
user := &portainer.User{
|
|
||||||
ID: 1,
|
|
||||||
Username: "standard",
|
|
||||||
Role: portainer.StandardUserRole,
|
|
||||||
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
|
|
||||||
}
|
|
||||||
require.NoError(t, store.User().Create(user))
|
|
||||||
|
|
||||||
sc := &security.RestrictedRequestContext{
|
|
||||||
IsAdmin: false,
|
|
||||||
UserID: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}
|
|
||||||
dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{kubeStack, dockerStack}
|
|
||||||
|
|
||||||
var result []portainer.Stack
|
|
||||||
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
var txErr error
|
|
||||||
result, txErr = filterDockerStacksByAccess(tx, stacks, sc)
|
|
||||||
|
|
||||||
return txErr
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, 1)
|
|
||||||
require.Equal(t, "kube-stack", result[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sc := &security.RestrictedRequestContext{
|
|
||||||
IsAdmin: true,
|
|
||||||
UserID: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{
|
|
||||||
{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack},
|
|
||||||
{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := filterDockerStacksByAccess(nil, stacks, sc)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
sc := &security.RestrictedRequestContext{
|
|
||||||
IsAdmin: true,
|
|
||||||
UserID: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
|
||||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
|
||||||
2: {ID: 2, Type: portainer.DockerEnvironment},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := buildEndpointAccessMap(nil, sc, endpointMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, 1)
|
|
||||||
require.True(t, result[1].isKubeAdmin)
|
|
||||||
require.Empty(t, result[1].nonAdminNamespaces)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
|
|
||||||
deployment := &appsv1.Deployment{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: "my-app",
|
|
||||||
Namespace: "default",
|
|
||||||
Labels: map[string]string{
|
|
||||||
"io.portainer.kubernetes.application.stackid": "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: appsv1.DeploymentSpec{
|
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
|
||||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{
|
|
||||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
|
||||||
}
|
|
||||||
|
|
||||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
|
||||||
1: {isKubeAdmin: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, 1)
|
|
||||||
assert.Equal(t, "my-app", result[0].Name)
|
|
||||||
assert.Equal(t, "default", result[0].Namespace)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
|
||||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{
|
|
||||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
|
||||||
}
|
|
||||||
|
|
||||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
|
||||||
1: {isKubeAdmin: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
|
|
||||||
deployment := &appsv1.Deployment{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: "my-app",
|
|
||||||
Namespace: "ns1",
|
|
||||||
Labels: map[string]string{
|
|
||||||
"io.portainer.kubernetes.application.stackid": "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: appsv1.DeploymentSpec{
|
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
|
||||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{
|
|
||||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
|
||||||
}
|
|
||||||
|
|
||||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
|
||||||
1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, result, 1)
|
|
||||||
assert.Equal(t, "my-app", result[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
ep := &portainer.Endpoint{
|
|
||||||
ID: 1,
|
|
||||||
Type: portainer.KubernetesLocalEnvironment,
|
|
||||||
}
|
|
||||||
|
|
||||||
sc := &security.RestrictedRequestContext{
|
|
||||||
IsAdmin: false,
|
|
||||||
UserID: 1,
|
|
||||||
UserMemberships: []portainer.TeamMembership{
|
|
||||||
{TeamID: 5},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
access, err := resolveKubeAccess(factory, sc, ep)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, access.isKubeAdmin)
|
|
||||||
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveKubeAccess_NonAdmin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
ep := &portainer.Endpoint{
|
|
||||||
ID: 1,
|
|
||||||
Type: portainer.KubernetesLocalEnvironment,
|
|
||||||
}
|
|
||||||
|
|
||||||
sc := &security.RestrictedRequestContext{
|
|
||||||
IsAdmin: false,
|
|
||||||
UserID: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
access, err := resolveKubeAccess(factory, sc, ep)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, access.isKubeAdmin)
|
|
||||||
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fakeKubeClient := kfake.NewSimpleClientset()
|
|
||||||
|
|
||||||
deployment := &appsv1.Deployment{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: "my-app",
|
|
||||||
Namespace: "ns1",
|
|
||||||
Labels: map[string]string{
|
|
||||||
"io.portainer.kubernetes.application.stackid": "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: appsv1.DeploymentSpec{
|
|
||||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
kcl := cli.NewTestKubeClient(fakeKubeClient)
|
|
||||||
factory := cli.NewTestClientFactory(1, kcl)
|
|
||||||
|
|
||||||
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
|
|
||||||
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks := []portainer.Stack{
|
|
||||||
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
|
|
||||||
}
|
|
||||||
|
|
||||||
accessMap := map[portainer.EndpointID]endpointAccess{
|
|
||||||
1: {isKubeAdmin: false, nonAdminNamespaces: []string{}},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, result)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package workflows
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
|
||||||
wf "github.com/portainer/portainer/api/gitops/workflows"
|
|
||||||
)
|
|
||||||
|
|
||||||
func computeGitPhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact wf.WorkflowPhaseStatus) {
|
|
||||||
if gitSvc == nil || cfg == nil {
|
|
||||||
return wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}, wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}
|
|
||||||
}
|
|
||||||
|
|
||||||
username, password := gitCredentials(cfg)
|
|
||||||
return wf.ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath,
|
|
||||||
func(ctx context.Context) ([]string, error) {
|
|
||||||
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
|
|
||||||
},
|
|
||||||
func(ctx context.Context, exts []string) ([]string, error) {
|
|
||||||
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
|
|
||||||
if cfg.Authentication != nil {
|
|
||||||
return cfg.Authentication.Username, cfg.Authentication.Password
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,9 @@ import (
|
|||||||
|
|
||||||
gocache "github.com/patrickmn/go-cache"
|
gocache "github.com/patrickmn/go-cache"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
svc "github.com/portainer/portainer/api/gitops/workflows"
|
svc "github.com/portainer/portainer/api/gitops/workflows"
|
||||||
"github.com/portainer/portainer/api/http/models/kubernetes"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/http/utils/filters"
|
"github.com/portainer/portainer/api/http/utils/filters"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
|
||||||
"github.com/portainer/portainer/api/set"
|
"github.com/portainer/portainer/api/set"
|
||||||
"github.com/portainer/portainer/api/slicesx"
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
@@ -141,115 +137,7 @@ func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
|
func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
|
||||||
var entries []portainer.Stack
|
return svc.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, endpointIDSet)
|
||||||
var endpointMap map[portainer.EndpointID]portainer.Endpoint
|
|
||||||
|
|
||||||
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
|
||||||
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointMap, err = buildEndpointMap(tx, stacks)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range stacks {
|
|
||||||
s := stacks[i]
|
|
||||||
|
|
||||||
if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]svc.Workflow, 0, len(entries))
|
|
||||||
for _, s := range entries {
|
|
||||||
source, artifact := computeGitPhases(ctx, h.gitService, s.GitConfig)
|
|
||||||
items = append(items, svc.MapStackToWorkflow(s, s.GitConfig, source, artifact))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookup only if env is kube and either not edge or (edge + not async)
|
|
||||||
func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
|
|
||||||
return endpointutils.IsKubernetesEndpoint(endpoint) &&
|
|
||||||
(!endpointutils.IsEdgeEndpoint(endpoint) ||
|
|
||||||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
|
|
||||||
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
|
|
||||||
return s.Type == portainer.KubernetesStack
|
|
||||||
})
|
|
||||||
|
|
||||||
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
|
|
||||||
return s.EndpointID
|
|
||||||
})
|
|
||||||
|
|
||||||
for envID, stacks := range groupedByEnvId {
|
|
||||||
ep, ok := endpointMap[envID]
|
|
||||||
if !ok || !shouldPerformEnvLookup(&ep) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
access := accessMap[envID]
|
|
||||||
kcl.SetIsKubeAdmin(access.isKubeAdmin)
|
|
||||||
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
|
|
||||||
|
|
||||||
apps, err := kcl.GetApplications("", "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range stacks {
|
|
||||||
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
|
|
||||||
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
|
|
||||||
})
|
|
||||||
if idx == -1 {
|
|
||||||
// if we don't find a matching application (deployment/statefulset/daemonset) in the environment workloads
|
|
||||||
// this workflow (stack) wouldn't show in the Applications list, so we don't keep it
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
app := apps[idx]
|
|
||||||
|
|
||||||
s.Name = app.Name
|
|
||||||
s.Namespace = app.ResourcePool
|
|
||||||
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
|
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ angular
|
|||||||
name: 'portainer.home',
|
name: 'portainer.home',
|
||||||
url: '/home?redirect&environmentId&environmentName&route&groupBy&groupFilter&search&order',
|
url: '/home?redirect&environmentId&environmentName&route&groupBy&groupFilter&search&order',
|
||||||
params: {
|
params: {
|
||||||
...paginationParams('id'),
|
...paginationParams('Id'),
|
||||||
groupBy: filterParam(),
|
groupBy: filterParam('Id'),
|
||||||
groupFilter: filterParam(),
|
groupFilter: filterParam(),
|
||||||
},
|
},
|
||||||
views: {
|
views: {
|
||||||
@@ -294,8 +294,14 @@ angular
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var gitopsBase = {
|
||||||
|
name: 'portainer.gitops',
|
||||||
|
url: '/gitops',
|
||||||
|
abstract: true,
|
||||||
|
};
|
||||||
|
|
||||||
var workflows = {
|
var workflows = {
|
||||||
name: 'portainer.workflows',
|
name: 'portainer.gitops.workflows',
|
||||||
url: '/workflows?search&sort&order&page&pageSize&status&type&platform&groupBy&groupFilter',
|
url: '/workflows?search&sort&order&page&pageSize&status&type&platform&groupBy&groupFilter',
|
||||||
params: {
|
params: {
|
||||||
...paginationParams('name'),
|
...paginationParams('name'),
|
||||||
@@ -312,6 +318,21 @@ angular
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var gitopsSources = {
|
||||||
|
name: 'portainer.gitops.sources',
|
||||||
|
url: '/sources?search&sort&order&page&pageSize&status&type',
|
||||||
|
params: {
|
||||||
|
...paginationParams('name'),
|
||||||
|
status: filterParam(),
|
||||||
|
type: filterParam(),
|
||||||
|
},
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'sourcesListView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
var init = {
|
var init = {
|
||||||
name: 'portainer.init',
|
name: 'portainer.init',
|
||||||
abstract: true,
|
abstract: true,
|
||||||
@@ -432,7 +453,9 @@ angular
|
|||||||
$stateRegistryProvider.register(groupAccess);
|
$stateRegistryProvider.register(groupAccess);
|
||||||
$stateRegistryProvider.register(groupCreation);
|
$stateRegistryProvider.register(groupCreation);
|
||||||
$stateRegistryProvider.register(home);
|
$stateRegistryProvider.register(home);
|
||||||
|
$stateRegistryProvider.register(gitopsBase);
|
||||||
$stateRegistryProvider.register(workflows);
|
$stateRegistryProvider.register(workflows);
|
||||||
|
$stateRegistryProvider.register(gitopsSources);
|
||||||
$stateRegistryProvider.register(init);
|
$stateRegistryProvider.register(init);
|
||||||
$stateRegistryProvider.register(initAdmin);
|
$stateRegistryProvider.register(initAdmin);
|
||||||
$stateRegistryProvider.register(settings);
|
$stateRegistryProvider.register(settings);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView';
|
||||||
|
import { ListView as SourcesListView } from '@/react/portainer/gitops/sources/ListView/ListView';
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
|
||||||
|
export const gitopsViewsModule = angular
|
||||||
|
.module('portainer.app.react.views.gitops', [])
|
||||||
|
|
||||||
|
.component(
|
||||||
|
'workflowsView',
|
||||||
|
r2a(withUIRouter(withCurrentUser(WorkflowsView)), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'sourcesListView',
|
||||||
|
r2a(withUIRouter(withCurrentUser(SourcesListView)), [])
|
||||||
|
).name;
|
||||||
@@ -10,7 +10,6 @@ import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeV
|
|||||||
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
||||||
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
|
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
|
||||||
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
|
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
|
||||||
import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView';
|
|
||||||
|
|
||||||
import { wizardModule } from './wizard';
|
import { wizardModule } from './wizard';
|
||||||
import { teamsModule } from './teams';
|
import { teamsModule } from './teams';
|
||||||
@@ -21,6 +20,7 @@ import { activityLogsModule } from './activity-logs';
|
|||||||
import { templatesModule } from './templates';
|
import { templatesModule } from './templates';
|
||||||
import { usersModule } from './users';
|
import { usersModule } from './users';
|
||||||
import { environmentsModule } from './environments';
|
import { environmentsModule } from './environments';
|
||||||
|
import { gitopsViewsModule } from './gitops';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.app.react.views', [
|
.module('portainer.app.react.views', [
|
||||||
@@ -33,6 +33,7 @@ export const viewsModule = angular
|
|||||||
templatesModule,
|
templatesModule,
|
||||||
usersModule,
|
usersModule,
|
||||||
environmentsModule,
|
environmentsModule,
|
||||||
|
gitopsViewsModule,
|
||||||
])
|
])
|
||||||
.component(
|
.component(
|
||||||
'homeView',
|
'homeView',
|
||||||
@@ -67,8 +68,4 @@ export const viewsModule = angular
|
|||||||
withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))),
|
withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'workflowsView',
|
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(WorkflowsView))), [])
|
|
||||||
).name;
|
).name;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Props {
|
|||||||
badge?: string | null;
|
badge?: string | null;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
'aria-pressed'?: boolean;
|
||||||
'data-cy'?: string;
|
'data-cy'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export function DropdownMenu({
|
|||||||
badge,
|
badge,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
|
'aria-pressed': ariaPressed,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +68,7 @@ export function DropdownMenu({
|
|||||||
<ReachMenuButton
|
<ReachMenuButton
|
||||||
className={clsx('group flex gap-1', className)}
|
className={clsx('group flex gap-1', className)}
|
||||||
onClick={() => onClick?.()}
|
onClick={() => onClick?.()}
|
||||||
|
aria-pressed={ariaPressed}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
interface Props {
|
|
||||||
label: string;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterBarActiveIndicator({ label, onClear }: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-4 whitespace-nowrap bg-[var(--bg-blocklist-hover-color)] px-5"
|
|
||||||
data-cy="active-filter-indicator"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-[var(--text-muted-color)]">
|
|
||||||
Showing:{' '}
|
|
||||||
<span className="font-semibold text-[var(--text-summary-color)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="close-button cursor-pointer border-0 bg-transparent p-0 text-[21px] font-bold leading-none text-[var(--button-close-color)] opacity-[var(--button-opacity)] hover:opacity-[var(--button-opacity-hover)]"
|
|
||||||
onClick={onClear}
|
|
||||||
aria-label="Clear filter"
|
|
||||||
data-cy="clear-filter-button"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
import { FilterBarButton } from './FilterBarButton';
|
|
||||||
|
|
||||||
function renderComponent(
|
|
||||||
props: Partial<React.ComponentProps<typeof FilterBarButton>> = {}
|
|
||||||
) {
|
|
||||||
const defaultProps: React.ComponentProps<typeof FilterBarButton> = {
|
|
||||||
count: 5,
|
|
||||||
label: 'Running',
|
|
||||||
isSelected: false,
|
|
||||||
onClick: vi.fn(),
|
|
||||||
name: 'status-filter',
|
|
||||||
'data-cy': 'filter-bar-button',
|
|
||||||
...props,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...render(<FilterBarButton {...defaultProps} />),
|
|
||||||
props: defaultProps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FilterBarButton', () => {
|
|
||||||
it('should render count and label, and call onClick when clicked', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onClick = vi.fn();
|
|
||||||
renderComponent({ onClick, colorScheme: 'success' });
|
|
||||||
|
|
||||||
expect(screen.getByText('5')).toBeVisible();
|
|
||||||
expect(screen.getByText('Running')).toBeVisible();
|
|
||||||
expect(
|
|
||||||
screen.getByRole('radio', { name: /filter by running/i })
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await user.click(screen.getByText('Running'));
|
|
||||||
expect(onClick).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when count is 0', () => {
|
|
||||||
const { container } = renderComponent({ count: 0 });
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
|
||||||
|
|
||||||
export type FilterBarColorScheme =
|
|
||||||
| 'success'
|
|
||||||
| 'error'
|
|
||||||
| 'warning'
|
|
||||||
| 'blue'
|
|
||||||
| 'gray';
|
|
||||||
|
|
||||||
const colorSchemeStyles: Record<
|
|
||||||
FilterBarColorScheme,
|
|
||||||
{ dot: string; text: string }
|
|
||||||
> = {
|
|
||||||
success: { dot: 'bg-success-7', text: 'text-success-7' },
|
|
||||||
error: { dot: 'bg-error-7', text: 'text-error-7' },
|
|
||||||
warning: { dot: 'bg-warning-7', text: 'text-warning-7' },
|
|
||||||
blue: { dot: 'bg-blue-7', text: 'text-blue-7' },
|
|
||||||
gray: { dot: 'bg-gray-7', text: 'text-gray-7' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props extends AutomationTestingProps {
|
|
||||||
count: number;
|
|
||||||
label: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
name: string;
|
|
||||||
colorScheme?: FilterBarColorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterBarButton({
|
|
||||||
count,
|
|
||||||
label,
|
|
||||||
isSelected,
|
|
||||||
onClick,
|
|
||||||
name,
|
|
||||||
colorScheme,
|
|
||||||
'data-cy': dataCy,
|
|
||||||
}: Props) {
|
|
||||||
if (count === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = colorScheme ? colorSchemeStyles[colorScheme] : undefined;
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
'relative mb-0 flex items-center gap-2',
|
|
||||||
'px-8 py-3',
|
|
||||||
'cursor-pointer border-0',
|
|
||||||
'text-sm font-medium',
|
|
||||||
'text-[var(--text-muted-color)]',
|
|
||||||
'hover:bg-[var(--bg-blocklist-item-selected-color)]',
|
|
||||||
'transition-colors duration-150',
|
|
||||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-blue-5 has-[:focus-visible]:ring-inset',
|
|
||||||
isSelected && 'bg-[var(--bg-blocklist-item-selected-color)]'
|
|
||||||
)}
|
|
||||||
data-cy={dataCy}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className="sr-only"
|
|
||||||
name={name}
|
|
||||||
value={label}
|
|
||||||
checked={isSelected}
|
|
||||||
onClick={onClick}
|
|
||||||
readOnly
|
|
||||||
aria-label={`Filter by ${label}`}
|
|
||||||
tabIndex={0}
|
|
||||||
/>
|
|
||||||
{colors && (
|
|
||||||
<span
|
|
||||||
className={clsx('h-2.5 w-2.5 shrink-0 rounded-full', colors.dot)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{colors && (
|
|
||||||
<span className="flex flex-col leading-tight">
|
|
||||||
<span className={clsx('text-2xl font-bold', colors.text)}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs uppercase tracking-wide text-[var(--text-muted-color)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!colors && (
|
|
||||||
<span className="flex items-baseline gap-2">
|
|
||||||
<span className="text-2xl font-bold">{count}</span>
|
|
||||||
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isSelected && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute bottom-0 left-0 right-0 h-1',
|
|
||||||
colors?.dot || 'bg-blue-7'
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
import {
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useContext,
|
|
||||||
createContext,
|
|
||||||
ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
@@ -14,108 +6,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
|
|||||||
|
|
||||||
import { SortByGroup, SortOption } from './SortByGroup';
|
import { SortByGroup, SortOption } from './SortByGroup';
|
||||||
|
|
||||||
type MenuCtxType = {
|
vi.mock('@reach/menu-button');
|
||||||
isOpen: boolean;
|
|
||||||
setOpen: (v: boolean) => void;
|
|
||||||
menuRef: React.RefObject<HTMLDivElement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('@reach/menu-button', () => {
|
|
||||||
const MenuCtx = createContext<MenuCtxType | null>(null);
|
|
||||||
|
|
||||||
function Menu({ children }: { children?: ReactNode }) {
|
|
||||||
const [isOpen, setOpen] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleDocDown(e: MouseEvent) {
|
|
||||||
const target = e.target as Node | null;
|
|
||||||
if (
|
|
||||||
isOpen &&
|
|
||||||
menuRef.current &&
|
|
||||||
target &&
|
|
||||||
!menuRef.current.contains(target)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleDocDown);
|
|
||||||
return () => document.removeEventListener('mousedown', handleDocDown);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef }}>
|
|
||||||
<div ref={menuRef}>{children}</div>
|
|
||||||
</MenuCtx.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MenuButton({
|
|
||||||
children,
|
|
||||||
onClick: externalOnClick,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}) {
|
|
||||||
const ctx = useContext(MenuCtx);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
externalOnClick?.();
|
|
||||||
ctx?.setOpen(!ctx.isOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={handleClick} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MenuList({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const ctx = useContext(MenuCtx);
|
|
||||||
if (!ctx?.isOpen) return null;
|
|
||||||
return (
|
|
||||||
<div role="menu" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MenuItem({
|
|
||||||
children,
|
|
||||||
onSelect,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
onSelect?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const ctx = useContext(MenuCtx);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
onSelect?.();
|
|
||||||
ctx?.setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
|
|
||||||
<div role="menuitem" onClick={handleClick} className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { Menu, MenuButton, MenuList, MenuItem };
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortOptions: SortOption[] = [
|
const sortOptions: SortOption[] = [
|
||||||
{ key: 'Group', label: 'Group', grouped: true },
|
{ key: 'Group', label: 'Group', grouped: true },
|
||||||
@@ -135,105 +26,106 @@ const groupOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderComponent({
|
function renderComponent({
|
||||||
activeKey = 'Group' as string,
|
group = 'Group' as string,
|
||||||
groupFilter = null as string | null,
|
groupValue = null as string | null,
|
||||||
onSortChange = vi.fn(),
|
onChange = vi.fn(),
|
||||||
onGroupFilterChange = vi.fn(),
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const Wrapped = withTestQueryProvider(
|
const Wrapped = withTestQueryProvider(
|
||||||
withTestRouter(() => (
|
withTestRouter(() => (
|
||||||
<SortByGroup
|
<SortByGroup
|
||||||
activeKey={activeKey}
|
value={{ group, groupValue }}
|
||||||
sortDesc={false}
|
onChange={onChange}
|
||||||
onSortChange={onSortChange}
|
|
||||||
sortOptions={sortOptions}
|
sortOptions={sortOptions}
|
||||||
groupFilter={groupFilter}
|
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
onGroupFilterChange={onGroupFilterChange}
|
sortDesc={false}
|
||||||
dataCy="test"
|
dataCy="test"
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
return { ...render(<Wrapped />), onSortChange, onGroupFilterChange };
|
return { ...render(<Wrapped />), onChange };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SortByGroup', () => {
|
describe('SortByGroup', () => {
|
||||||
describe('grouped: false option', () => {
|
describe('grouped: false option', () => {
|
||||||
test('clicking an inactive button calls onSortChange', async () => {
|
test('clicking an inactive button calls onChange with { group, groupValue: null }', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange } = renderComponent({ activeKey: 'Group' });
|
const { onChange } = renderComponent({ group: 'Group' });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Name$/i }));
|
await user.click(screen.getByRole('button', { name: /^Name$/i }));
|
||||||
|
|
||||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
group: 'Name',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => {
|
test('clicking the already-active button does calls onChange', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange } = renderComponent({ activeKey: 'Name' });
|
const { onChange } = renderComponent({ group: 'Name' });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
|
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
|
||||||
|
|
||||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
group: 'Name',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('grouped: true option', () => {
|
describe('grouped: true option', () => {
|
||||||
test('clicking the dropdown button to open it does not call onSortChange', async () => {
|
test('clicking the dropdown button to open it does not call onChange', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange } = renderComponent({ activeKey: 'Group' });
|
const { onChange } = renderComponent({ group: 'Group' });
|
||||||
|
|
||||||
// Click Platform button (inactive grouped option) — should just open menu
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
|
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
|
||||||
|
|
||||||
expect(onSortChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('menu')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting a filter from an inactive grouped option calls onGroupFilterChange with the group key and value', async () => {
|
test('selecting a filter from an inactive grouped option calls onChange with both keys', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange, onGroupFilterChange } = renderComponent({
|
const { onChange } = renderComponent({ group: 'Group' });
|
||||||
activeKey: 'Group',
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
|
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
|
||||||
await user.click(screen.getByRole('menuitem', { name: /Docker/ }));
|
await user.click(screen.getByRole('menuitem', { name: /Docker/ }));
|
||||||
|
|
||||||
expect(onSortChange).not.toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||||
expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith(
|
group: 'Platform',
|
||||||
'Platform',
|
groupValue: 'Docker',
|
||||||
'Docker'
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting a filter from the already-active grouped option does not call onSortChange', async () => {
|
test('selecting a filter from the already-active grouped option calls onChange', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange, onGroupFilterChange } = renderComponent({
|
const { onChange } = renderComponent({
|
||||||
activeKey: 'Group',
|
group: 'Group',
|
||||||
groupFilter: null,
|
groupValue: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Group$/i }));
|
await user.click(screen.getByRole('button', { name: /^Group$/i }));
|
||||||
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
|
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
|
||||||
|
|
||||||
expect(onSortChange).not.toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalledExactlyOnceWith({
|
||||||
expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith(
|
group: 'Group',
|
||||||
'Group',
|
groupValue: 'GroupA',
|
||||||
'GroupA'
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting All from a grouped dropdown calls onGroupFilterChange with null', async () => {
|
test('selecting All from a grouped dropdown calls onChange with (key, null)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSortChange, onGroupFilterChange } = renderComponent({
|
const { onChange } = renderComponent({
|
||||||
activeKey: 'Group',
|
group: 'Group',
|
||||||
groupFilter: 'GroupA',
|
groupValue: 'GroupA',
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /^Group/i }));
|
await user.click(screen.getByRole('button', { name: /^Group/i }));
|
||||||
await user.click(screen.getByRole('menuitem', { name: /^All$/ }));
|
await user.click(screen.getByRole('menuitem', { name: /^All$/ }));
|
||||||
|
|
||||||
expect(onSortChange).not.toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
expect(onGroupFilterChange).toHaveBeenCalledWith('Group', null);
|
group: 'Group',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,25 +10,26 @@ export interface SortOption<TSortKey extends string = string> {
|
|||||||
ascendingLabel?: string;
|
ascendingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Value<TSortKey> = {
|
||||||
|
group: TSortKey;
|
||||||
|
groupValue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface SortByGroupProps<TSortKey extends string> {
|
export interface SortByGroupProps<TSortKey extends string> {
|
||||||
activeKey: TSortKey;
|
value: Value<TSortKey>;
|
||||||
sortDesc: boolean;
|
sortDesc: boolean;
|
||||||
onSortChange: (key: TSortKey) => void;
|
onChange: (value: Value<TSortKey>) => void;
|
||||||
sortOptions: SortOption<TSortKey>[];
|
sortOptions: SortOption<TSortKey>[];
|
||||||
groupFilter: string | null;
|
|
||||||
groupOptions?: Record<string, DropdownOption[]>;
|
groupOptions?: Record<string, DropdownOption[]>;
|
||||||
onGroupFilterChange: (group: string | null, filter: string | null) => void;
|
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortByGroup<TSortKey extends string>({
|
export function SortByGroup<TSortKey extends string>({
|
||||||
activeKey,
|
value,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
onSortChange,
|
onChange,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
groupFilter,
|
|
||||||
groupOptions,
|
groupOptions,
|
||||||
onGroupFilterChange,
|
|
||||||
dataCy,
|
dataCy,
|
||||||
}: SortByGroupProps<TSortKey>) {
|
}: SortByGroupProps<TSortKey>) {
|
||||||
return (
|
return (
|
||||||
@@ -52,14 +53,13 @@ export function SortByGroup<TSortKey extends string>({
|
|||||||
<SortOptionItem
|
<SortOptionItem
|
||||||
key={option.key}
|
key={option.key}
|
||||||
option={option}
|
option={option}
|
||||||
isActive={activeKey === option.key}
|
isActive={value.group === option.key}
|
||||||
sortDesc={sortDesc}
|
sortDesc={sortDesc}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
isLast={index === sortOptions.length - 1}
|
isLast={index === sortOptions.length - 1}
|
||||||
onSortChange={onSortChange}
|
value={value}
|
||||||
groupFilter={groupFilter}
|
onChange={(value) => onChange(value)}
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
onGroupFilterChange={onGroupFilterChange}
|
|
||||||
dataCy={dataCy}
|
dataCy={dataCy}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -90,10 +90,9 @@ interface SortOptionItemProps<TSortKey extends string> {
|
|||||||
sortDesc: boolean;
|
sortDesc: boolean;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
onSortChange: (key: TSortKey) => void;
|
value: Value<TSortKey>;
|
||||||
groupFilter: string | null;
|
onChange: (value: Value<TSortKey>) => void;
|
||||||
groupOptions?: Record<string, DropdownOption[]>;
|
groupOptions?: Record<string, DropdownOption[]>;
|
||||||
onGroupFilterChange: (group: string | null, filter: string | null) => void;
|
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +102,9 @@ function SortOptionItem<TSortKey extends string>({
|
|||||||
sortDesc,
|
sortDesc,
|
||||||
isFirst,
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
onSortChange,
|
value,
|
||||||
groupFilter,
|
onChange,
|
||||||
groupOptions,
|
groupOptions,
|
||||||
onGroupFilterChange,
|
|
||||||
dataCy,
|
dataCy,
|
||||||
}: SortOptionItemProps<TSortKey>) {
|
}: SortOptionItemProps<TSortKey>) {
|
||||||
const className = clsx(
|
const className = clsx(
|
||||||
@@ -121,16 +119,17 @@ function SortOptionItem<TSortKey extends string>({
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
label={option.label}
|
label={option.label}
|
||||||
options={groupOptions?.[option.key]}
|
options={groupOptions?.[option.key]}
|
||||||
selected={groupFilter}
|
selected={isActive ? value.groupValue ?? null : null}
|
||||||
onSelect={(value) => {
|
onSelect={(selected) => {
|
||||||
onGroupFilterChange(option.key, value);
|
onChange({ group: option.key, groupValue: selected });
|
||||||
}}
|
}}
|
||||||
badge={
|
badge={
|
||||||
isActive
|
isActive
|
||||||
? getFilterBadge(groupOptions, option.key, groupFilter)
|
? getFilterBadge(groupOptions, option.key, value.groupValue ?? null)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={className}
|
className={className}
|
||||||
|
aria-pressed={isActive}
|
||||||
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -146,8 +145,9 @@ function SortOptionItem<TSortKey extends string>({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={className}
|
className={className}
|
||||||
|
aria-pressed={isActive}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSortChange(option.key);
|
onChange({ group: option.key, groupValue: null });
|
||||||
}}
|
}}
|
||||||
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { SortableList, SortableListState, SortableGroup } from './SortableList';
|
||||||
SortableList,
|
|
||||||
SortableListState,
|
|
||||||
SortableGroup,
|
|
||||||
computeSortDesc,
|
|
||||||
} from './SortableList';
|
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,14 +35,17 @@ describe('SortableList', () => {
|
|||||||
expect(screen.getByText('Theta')).toBeInTheDocument();
|
expect(screen.getByText('Theta')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setSortBy when a sort option is clicked', async () => {
|
it('calls setGroupFilter when a sort option is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const setSortBy = vi.fn();
|
const setGroupFilter = vi.fn();
|
||||||
renderList({ state: { setSortBy } });
|
renderList({ state: { setGroupFilter } });
|
||||||
|
|
||||||
await user.click(screen.getByText('Status'));
|
await user.click(screen.getByText('Status'));
|
||||||
|
|
||||||
expect(setSortBy).toHaveBeenCalledWith('status', false);
|
expect(setGroupFilter).toHaveBeenCalledWith({
|
||||||
|
group: 'status',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows group headers when showGroupHeaders=true and multiple groups', () => {
|
it('shows group headers when showGroupHeaders=true and multiple groups', () => {
|
||||||
@@ -243,33 +241,6 @@ describe('SortableList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('computeSortDesc', () => {
|
|
||||||
it('flips ascending to descending when the same key is active', () => {
|
|
||||||
expect(computeSortDesc('name', 'name', false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flips descending to ascending when the same key is active', () => {
|
|
||||||
expect(computeSortDesc('name', 'name', true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets to ascending when a different key is clicked (was ascending)', () => {
|
|
||||||
expect(computeSortDesc('status', 'name', false)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets to ascending when a different key is clicked (was descending)', () => {
|
|
||||||
expect(computeSortDesc('status', 'name', true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets to ascending when a group key is active and a sort key is clicked', () => {
|
|
||||||
// groupBy is active so activeKey is the group option key, not the sort key
|
|
||||||
expect(computeSortDesc('name', 'Health', false)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets to ascending when there is no active key', () => {
|
|
||||||
expect(computeSortDesc('name', '', false)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderList({
|
function renderList({
|
||||||
state = createMockState(),
|
state = createMockState(),
|
||||||
groups = makeGroups(ITEMS),
|
groups = makeGroups(ITEMS),
|
||||||
|
|||||||
@@ -51,35 +51,27 @@ export function SortableList<T>({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const rawSortKey = tableState.sortBy?.id ?? '';
|
const activeKey = getSortKey(
|
||||||
const activeSortKey =
|
sortOptions,
|
||||||
sortOptions
|
tableState.groupBy ?? tableState.sortBy?.id ?? ''
|
||||||
.filter((o) => !o.grouped)
|
);
|
||||||
.find((opt) => opt.key.toLowerCase() === rawSortKey.toLowerCase())?.key ??
|
|
||||||
null;
|
|
||||||
const rawGroupKey = tableState.groupBy ?? '';
|
|
||||||
const activeGroupKey =
|
|
||||||
sortOptions
|
|
||||||
.filter((o) => o.grouped)
|
|
||||||
.find((opt) => opt.key.toLowerCase() === rawGroupKey.toLowerCase())
|
|
||||||
?.key ?? null;
|
|
||||||
const activeKey = activeGroupKey ?? activeSortKey ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableListCard>
|
<SortableListCard>
|
||||||
<SortableListHeader
|
<SortableListHeader
|
||||||
activeKey={activeKey}
|
value={{
|
||||||
sortDesc={tableState.sortBy?.desc ?? false}
|
group: activeKey,
|
||||||
onSortChange={(key) => {
|
groupValue: tableState.groupFilter,
|
||||||
tableState.setSortBy(
|
|
||||||
key,
|
|
||||||
computeSortDesc(key, activeKey, tableState.sortBy?.desc ?? false)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
|
onChange={({ group, groupValue }) => {
|
||||||
|
tableState.setGroupFilter({
|
||||||
|
group,
|
||||||
|
groupValue,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
sortDesc={tableState.sortBy?.desc ?? false}
|
||||||
searchTerm={tableState.search}
|
searchTerm={tableState.search}
|
||||||
onSearchChange={tableState.setSearch}
|
onSearchChange={tableState.setSearch}
|
||||||
groupFilter={tableState.groupFilter}
|
|
||||||
onGroupFilterChange={tableState.setGroupFilter}
|
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
sortOptions={sortOptions}
|
sortOptions={sortOptions}
|
||||||
searchPlaceholder={searchPlaceholder}
|
searchPlaceholder={searchPlaceholder}
|
||||||
@@ -114,10 +106,14 @@ export function SortableList<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeSortDesc(
|
function getSortKey(sortOptions: SortOption[], sortKey: string | undefined) {
|
||||||
key: string,
|
if (!sortKey) {
|
||||||
activeKey: string,
|
return '';
|
||||||
currentDesc: boolean
|
}
|
||||||
): boolean {
|
|
||||||
return activeKey === key ? !currentDesc : false;
|
const sortOption = sortOptions.find(
|
||||||
|
(opt) => opt.key.toLowerCase() === sortKey.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
return sortOption?.key ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,27 +28,29 @@ const sortOptions = [
|
|||||||
type SortKey = (typeof sortOptions)[number]['key'];
|
type SortKey = (typeof sortOptions)[number]['key'];
|
||||||
|
|
||||||
export function Interactive() {
|
export function Interactive() {
|
||||||
const [activeKey, setActiveKey] = useState<SortKey>('name');
|
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handleSortChange(key: string) {
|
const [value, setValue] = useState<{
|
||||||
setSortDesc((prev) => (activeKey === key ? !prev : false));
|
group: SortKey;
|
||||||
setActiveKey(key as SortKey);
|
groupValue: string | null;
|
||||||
}
|
}>({
|
||||||
|
group: 'name',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableListHeader
|
<SortableListHeader
|
||||||
activeKey={activeKey}
|
|
||||||
sortDesc={sortDesc}
|
sortDesc={sortDesc}
|
||||||
onSortChange={handleSortChange}
|
value={value}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
|
||||||
|
setValue(newValue);
|
||||||
|
}}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
sortOptions={[...sortOptions]}
|
sortOptions={[...sortOptions]}
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
groupFilter={groupFilter}
|
|
||||||
onGroupFilterChange={(_group, value) => setGroupFilter(value)}
|
|
||||||
searchPlaceholder="Search environments..."
|
searchPlaceholder="Search environments..."
|
||||||
data-cy="group-sort"
|
data-cy="group-sort"
|
||||||
/>
|
/>
|
||||||
@@ -56,26 +58,28 @@ export function Interactive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WithGroupFilter() {
|
export function WithGroupFilter() {
|
||||||
const [activeKey, setActiveKey] = useState<SortKey>('group');
|
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [value, setValue] = useState<{
|
||||||
function handleSortChange(key: string) {
|
group: SortKey;
|
||||||
setSortDesc((prev) => (activeKey === key ? !prev : false));
|
groupValue: string | null;
|
||||||
setActiveKey(key as SortKey);
|
}>({
|
||||||
}
|
group: 'group',
|
||||||
|
groupValue: 'Production',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableListHeader
|
<SortableListHeader
|
||||||
activeKey={activeKey}
|
|
||||||
sortDesc={sortDesc}
|
sortDesc={sortDesc}
|
||||||
onSortChange={handleSortChange}
|
value={value}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
|
||||||
|
setValue(newValue);
|
||||||
|
}}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
sortOptions={[...sortOptions]}
|
sortOptions={[...sortOptions]}
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
groupFilter="Production"
|
|
||||||
onGroupFilterChange={() => {}}
|
|
||||||
searchPlaceholder="Search environments..."
|
searchPlaceholder="Search environments..."
|
||||||
data-cy="group-sort"
|
data-cy="group-sort"
|
||||||
/>
|
/>
|
||||||
@@ -83,27 +87,28 @@ export function WithGroupFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WithActionButton() {
|
export function WithActionButton() {
|
||||||
const [activeKey, setActiveKey] = useState<SortKey>('name');
|
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
|
const [value, setValue] = useState<{
|
||||||
|
group: SortKey;
|
||||||
|
groupValue: string | null;
|
||||||
|
}>({
|
||||||
|
group: 'name',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handleSortChange(key: string) {
|
|
||||||
setSortDesc((prev) => (activeKey === key ? !prev : false));
|
|
||||||
setActiveKey(key as SortKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableListHeader
|
<SortableListHeader
|
||||||
activeKey={activeKey}
|
|
||||||
sortDesc={sortDesc}
|
sortDesc={sortDesc}
|
||||||
onSortChange={handleSortChange}
|
value={value}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
|
||||||
|
setValue(newValue);
|
||||||
|
}}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
sortOptions={[...sortOptions]}
|
sortOptions={[...sortOptions]}
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
groupFilter={groupFilter}
|
|
||||||
onGroupFilterChange={(_group, value) => setGroupFilter(value)}
|
|
||||||
actionButton={
|
actionButton={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
|
|||||||
|
|
||||||
import { SortableListHeader } from './SortableListHeader';
|
import { SortableListHeader } from './SortableListHeader';
|
||||||
|
|
||||||
|
vi.mock('@reach/menu-button');
|
||||||
|
|
||||||
const defaultSortOptions = [
|
const defaultSortOptions = [
|
||||||
{ key: 'Group' as const, label: 'Group', grouped: true },
|
{ key: 'Group' as const, label: 'Group', grouped: true },
|
||||||
{ key: 'Platform' as const, label: 'Platform', grouped: true },
|
{ key: 'Platform' as const, label: 'Platform', grouped: true },
|
||||||
@@ -20,15 +22,13 @@ function renderHeader(
|
|||||||
> = {}
|
> = {}
|
||||||
) {
|
) {
|
||||||
const props = {
|
const props = {
|
||||||
activeKey: 'Group' as string,
|
value: { group: 'Group' as string, groupValue: null as string | null },
|
||||||
sortDesc: false,
|
onChange: vi.fn(),
|
||||||
onSortChange: vi.fn(),
|
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
onSearchChange: vi.fn(),
|
onSearchChange: vi.fn(),
|
||||||
sortOptions: defaultSortOptions,
|
sortOptions: defaultSortOptions,
|
||||||
groupFilter: null,
|
|
||||||
groupOptions: { Group: defaultGroups, Platform: defaultGroups },
|
groupOptions: { Group: defaultGroups, Platform: defaultGroups },
|
||||||
onGroupFilterChange: vi.fn(),
|
sortDesc: false,
|
||||||
'data-cy': 'cy',
|
'data-cy': 'cy',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -42,7 +42,7 @@ function renderHeader(
|
|||||||
describe('GroupSortTableHeader', () => {
|
describe('GroupSortTableHeader', () => {
|
||||||
test('clicking the active sort button opens the dropdown with group options', async () => {
|
test('clicking the active sort button opens the dropdown with group options', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHeader({ activeKey: 'Group' });
|
renderHeader();
|
||||||
|
|
||||||
const groupBtn = screen.getByRole('button', { name: /Group/i });
|
const groupBtn = screen.getByRole('button', { name: /Group/i });
|
||||||
await user.click(groupBtn);
|
await user.click(groupBtn);
|
||||||
@@ -53,21 +53,21 @@ describe('GroupSortTableHeader', () => {
|
|||||||
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
|
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking an inactive grouped sort button opens the dropdown without calling onSortChange', async () => {
|
test('clicking an inactive grouped sort button opens the dropdown without calling onChange', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSortChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderHeader({ activeKey: 'Group', onSortChange });
|
renderHeader({ onChange, value: { group: 'Group', groupValue: null } });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Platform/i }));
|
await user.click(screen.getByRole('button', { name: /Platform/i }));
|
||||||
|
|
||||||
expect(onSortChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole('menu', { name: /Platform/i })).toBeVisible();
|
expect(screen.getByRole('menu', { name: /Platform/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dropdown shows group options when opened', async () => {
|
test('dropdown shows group options when opened', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHeader({
|
renderHeader({
|
||||||
activeKey: 'Group',
|
value: { group: 'Group', groupValue: null },
|
||||||
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe('GroupSortTableHeader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('active group filter is shown as a badge inside the active sort button', () => {
|
test('active group filter is shown as a badge inside the active sort button', () => {
|
||||||
renderHeader({ activeKey: 'Group', groupFilter: 'Docker' });
|
renderHeader({ value: { group: 'Group', groupValue: 'Docker' } });
|
||||||
|
|
||||||
const groupBtn = screen.getByRole('button', { name: /Group/i });
|
const groupBtn = screen.getByRole('button', { name: /Group/i });
|
||||||
expect(groupBtn).toHaveTextContent('Docker');
|
expect(groupBtn).toHaveTextContent('Docker');
|
||||||
@@ -87,7 +87,7 @@ describe('GroupSortTableHeader', () => {
|
|||||||
|
|
||||||
test('clicking outside the dropdown closes it', async () => {
|
test('clicking outside the dropdown closes it', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHeader({ activeKey: 'Group' });
|
renderHeader({ value: { group: 'Group', groupValue: null } });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||||
expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible();
|
expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible();
|
||||||
@@ -133,8 +133,7 @@ describe('GroupSortTableHeader', () => {
|
|||||||
test('All option is present in dropdown menu', async () => {
|
test('All option is present in dropdown menu', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHeader({
|
renderHeader({
|
||||||
activeKey: 'Group',
|
value: { group: 'Group', groupValue: 'Docker' },
|
||||||
groupFilter: 'Docker',
|
|
||||||
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,7 +145,7 @@ describe('GroupSortTableHeader', () => {
|
|||||||
test('displays group counts in dropdown', async () => {
|
test('displays group counts in dropdown', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHeader({
|
renderHeader({
|
||||||
activeKey: 'Group',
|
value: { group: 'Group', groupValue: null },
|
||||||
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,4 +165,35 @@ describe('GroupSortTableHeader', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Health/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Health/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selecting a group option calls onChange with the sort key and selected value', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderHeader({ onChange });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: /Docker/ }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
group: 'Group',
|
||||||
|
groupValue: 'Docker',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting All calls onChange with the sort key and null', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderHeader({
|
||||||
|
value: { group: 'Group', groupValue: 'Docker' },
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: /All/ }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
group: 'Group',
|
||||||
|
groupValue: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,32 +12,28 @@ import { SortByGroup, SortOption } from './SortByGroup';
|
|||||||
export type { SortOption };
|
export type { SortOption };
|
||||||
|
|
||||||
interface Props<TSortKey extends string> {
|
interface Props<TSortKey extends string> {
|
||||||
activeKey: TSortKey;
|
|
||||||
sortDesc: boolean;
|
sortDesc: boolean;
|
||||||
onSortChange: (key: TSortKey) => void;
|
value: { group: TSortKey; groupValue: string | null };
|
||||||
|
onChange: (value: { group: TSortKey; groupValue: string | null }) => void;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSearchChange: (term: string) => void;
|
onSearchChange: (term: string) => void;
|
||||||
sortOptions: SortOption<TSortKey>[];
|
sortOptions: SortOption<TSortKey>[];
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
actionButton?: ReactNode;
|
actionButton?: ReactNode;
|
||||||
groupFilter: string | null;
|
|
||||||
groupOptions?: Record<string, DropdownOption[]>;
|
groupOptions?: Record<string, DropdownOption[]>;
|
||||||
onGroupFilterChange: (group: string | null, filter: string | null) => void;
|
|
||||||
headerButtons?: ReactNode;
|
headerButtons?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableListHeader<TSortKey extends string>({
|
export function SortableListHeader<TSortKey extends string>({
|
||||||
activeKey,
|
|
||||||
sortDesc,
|
sortDesc,
|
||||||
onSortChange,
|
value,
|
||||||
|
onChange,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
searchPlaceholder = 'Filter...',
|
searchPlaceholder = 'Filter...',
|
||||||
actionButton,
|
actionButton,
|
||||||
groupFilter,
|
|
||||||
groupOptions,
|
groupOptions,
|
||||||
onGroupFilterChange,
|
|
||||||
headerButtons,
|
headerButtons,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props<TSortKey> & AutomationTestingProps) {
|
}: Props<TSortKey> & AutomationTestingProps) {
|
||||||
@@ -49,14 +45,12 @@ export function SortableListHeader<TSortKey extends string>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SortByGroup
|
<SortByGroup
|
||||||
activeKey={activeKey}
|
value={value}
|
||||||
sortDesc={sortDesc}
|
sortDesc={sortDesc}
|
||||||
onSortChange={onSortChange}
|
onChange={onChange}
|
||||||
sortOptions={sortOptions}
|
sortOptions={sortOptions}
|
||||||
groupFilter={groupFilter}
|
|
||||||
groupOptions={groupOptions}
|
groupOptions={groupOptions}
|
||||||
onGroupFilterChange={onGroupFilterChange}
|
dataCy={`${dataCy}-sort`}
|
||||||
data-cy={`${dataCy}-sort`}
|
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{headerButtons}
|
{headerButtons}
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// This story demonstrates how StatusSummaryBar and SortableList integrate:
|
||||||
|
// the summary bar filter and the group-sort dimension filter share state so
|
||||||
|
// that selecting a status from either control keeps both in sync.
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||||
|
import { userEvent, within, expect } from 'storybook/test';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
|
||||||
|
import {
|
||||||
|
StatusSummaryBar,
|
||||||
|
StatusSegment,
|
||||||
|
} from '@@/StatusSummaryBar/StatusSummaryBar';
|
||||||
|
|
||||||
|
import { SortableList, SortableGroup, SortableListState } from './SortableList';
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
platform: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: Item[] = [
|
||||||
|
{ id: 1, name: 'Alpha', status: 'error', type: 'stack', platform: 'linux' },
|
||||||
|
{ id: 2, name: 'Beta', status: 'healthy', type: 'stack', platform: 'linux' },
|
||||||
|
{ id: 3, name: 'Gamma', status: 'error', type: 'edge', platform: 'windows' },
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Delta',
|
||||||
|
status: 'healthy',
|
||||||
|
type: 'edge',
|
||||||
|
platform: 'windows',
|
||||||
|
},
|
||||||
|
{ id: 5, name: 'Eta', status: 'outdated', type: 'stack', platform: 'mac' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEGMENTS: StatusSegment<string>[] = [
|
||||||
|
{ key: 'error', label: 'Error', count: 2, color: 'error' },
|
||||||
|
{ key: 'healthy', label: 'Healthy', count: 2, color: 'success' },
|
||||||
|
{ key: 'outdated', label: 'Outdated', count: 1, color: 'warning' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'status', label: 'Status', grouped: true },
|
||||||
|
{ key: 'type', label: 'Type', grouped: true },
|
||||||
|
{ key: 'platform', label: 'Platform', grouped: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_OPTIONS: Record<string, Array<{ key: string; label: string }>> = {
|
||||||
|
status: SEGMENTS,
|
||||||
|
type: [
|
||||||
|
{ key: 'stack', label: 'Stack' },
|
||||||
|
{ key: 'edge', label: 'Edge' },
|
||||||
|
],
|
||||||
|
platform: [
|
||||||
|
{ key: 'linux', label: 'Linux' },
|
||||||
|
{ key: 'windows', label: 'Windows' },
|
||||||
|
{ key: 'mac', label: 'Mac' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SORT_KEYS = ['name', 'status', 'type', 'platform'] as const;
|
||||||
|
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
sort: string;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
status: string | null;
|
||||||
|
type: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGroups(items: Item[], sortBy: string): SortableGroup<Item>[] {
|
||||||
|
const options = GROUP_OPTIONS[sortBy];
|
||||||
|
if (!options) {
|
||||||
|
return items.length > 0 ? [{ key: 'all', label: 'All', items }] : [];
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
items: items.filter((item) => item[sortBy as keyof Item] === key),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
const [params, setParams] = useState<Params>({
|
||||||
|
sort: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
status: null,
|
||||||
|
type: null,
|
||||||
|
platform: null,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function patchParams(update: Record<string, unknown>) {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = { ...prev } as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(update)) {
|
||||||
|
next[k] = v ?? null;
|
||||||
|
}
|
||||||
|
return next as Params;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groupFilter, setGroupFilter } = buildGroupSortExtras({
|
||||||
|
urlState: params,
|
||||||
|
setUrlState: patchParams,
|
||||||
|
defaultSort: 'name',
|
||||||
|
sortKeys: SORT_KEYS,
|
||||||
|
dimensions: DIMENSIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDimension = DIMENSIONS.some((d) => d.key === params.sort);
|
||||||
|
|
||||||
|
const tableState: SortableListState = {
|
||||||
|
sortBy: { id: params.sort, desc: params.order === 'desc' },
|
||||||
|
setSortBy: (id, desc) =>
|
||||||
|
patchParams({
|
||||||
|
sort: id ?? 'name',
|
||||||
|
order: desc ? 'desc' : 'asc',
|
||||||
|
page: 0,
|
||||||
|
}),
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
setPageSize: (pageSize) => patchParams({ pageSize, page: 0 }),
|
||||||
|
page: params.page,
|
||||||
|
setPage: (page) => patchParams({ page }),
|
||||||
|
groupBy: isDimension ? params.sort : null,
|
||||||
|
setGroupBy: (groupBy) => patchParams({ sort: groupBy ?? 'name' }),
|
||||||
|
groupFilter,
|
||||||
|
setGroupFilter,
|
||||||
|
search: params.search,
|
||||||
|
setSearch: (search) => patchParams({ search, page: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterValue = params.status;
|
||||||
|
function setFilter(v: string | null) {
|
||||||
|
patchParams({ status: v, page: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters: summary-bar status filter + active dimension group filter
|
||||||
|
let filteredItems = ITEMS;
|
||||||
|
if (filterValue != null) {
|
||||||
|
filteredItems = filteredItems.filter((i) => i.status === filterValue);
|
||||||
|
}
|
||||||
|
const dimKey = isDimension && params.sort !== 'status' ? params.sort : null;
|
||||||
|
const dimFilter = dimKey
|
||||||
|
? (params[dimKey as keyof Params] as string | null)
|
||||||
|
: null;
|
||||||
|
if (dimKey && dimFilter != null) {
|
||||||
|
filteredItems = filteredItems.filter(
|
||||||
|
(i) => i[dimKey as keyof Item] === dimFilter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<StatusSummaryBar
|
||||||
|
total={ITEMS.length}
|
||||||
|
segments={SEGMENTS}
|
||||||
|
value={filterValue}
|
||||||
|
onChange={setFilter}
|
||||||
|
data-cy="status-bar"
|
||||||
|
/>
|
||||||
|
<SortableList
|
||||||
|
tableState={tableState}
|
||||||
|
sortOptions={SORT_OPTIONS}
|
||||||
|
groupOptions={GROUP_OPTIONS}
|
||||||
|
groups={buildGroups(filteredItems, params.sort)}
|
||||||
|
totalCount={filteredItems.length}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<div className="px-4 py-2 text-sm">{item.name}</div>
|
||||||
|
)}
|
||||||
|
showGroupHeaders
|
||||||
|
data-cy="list"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Design System/SortableList/StatusFilteredList',
|
||||||
|
component: Demo,
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
} satisfies Meta<typeof Demo>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
// Click "Error" in summary bar → filter=error, sort=Name unchanged
|
||||||
|
export const SummaryBarFiltersToError: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
expectSortActive(canvas, 'Name');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click "Error" again (active) → filter clears back to total
|
||||||
|
export const SummaryBarTogglesOff: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
expectFilterChecked(canvas, 'total');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary bar active, click sort "Status" → status filter clears, sort=Status
|
||||||
|
export const SortStatusClearsSummaryBarFilter: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Status', 'All');
|
||||||
|
expectFilterChecked(canvas, 'total');
|
||||||
|
expectSortActive(canvas, 'Status');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary bar active, click sort "Type" → status filter persists, sort=Type
|
||||||
|
export const SortTypePersistsSummaryBarFilter: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Type', 'Stack');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
expectSortActive(canvas, 'Type');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switching to a non-grouped sort clears the group-set filter
|
||||||
|
export const SwitchToNameClearsGroupFilter: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSortOption(canvas, 'Status', 'Error');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Name');
|
||||||
|
expectFilterChecked(canvas, 'total');
|
||||||
|
expectSortActive(canvas, 'Name');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click "Error" in Status group sort → sort=Status, summary bar shows "Error"
|
||||||
|
export const StatusGroupFilterSyncsSummaryBar: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSortOption(canvas, 'Status', 'Error');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
expectSortActive(canvas, 'Status');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary bar filter persists when switching to a different dimension
|
||||||
|
export const SummaryBarFilterPersistsAcrossDimensions: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Type', 'Stack');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
expectSortActive(canvas, 'Type');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Second group switch clears the old dimension, summary bar filter persists
|
||||||
|
export const SecondGroupSwitchClearsOldDimension: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Type', 'Stack');
|
||||||
|
await clickSortOption(canvas, 'Platform', 'Linux');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
expectSortActive(canvas, 'Platform');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deselecting a group value keeps the sort, clears the filter
|
||||||
|
export const DeselectGroupValueClearsFilter: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSortOption(canvas, 'Status', 'Error');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
await clickSortOption(canvas, 'Status', 'All');
|
||||||
|
expectFilterChecked(canvas, 'total');
|
||||||
|
expectSortActive(canvas, 'Status');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary bar overrides a group-set filter
|
||||||
|
export const SummaryBarOverridesGroupFilter: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSortOption(canvas, 'Status', 'Error');
|
||||||
|
expectFilterChecked(canvas, 'error');
|
||||||
|
await clickSummaryBarOption(canvas, 'healthy');
|
||||||
|
expectFilterChecked(canvas, 'healthy');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group value overrides a summary bar filter; summary bar updates
|
||||||
|
export const GroupFilterOverridesSummaryBar: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await clickSummaryBarOption(canvas, 'healthy');
|
||||||
|
expectFilterChecked(canvas, 'healthy');
|
||||||
|
await clickSortOption(canvas, 'Status', 'Outdated');
|
||||||
|
expectFilterChecked(canvas, 'outdated');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helpers shared across play functions
|
||||||
|
async function clickSummaryBarOption(
|
||||||
|
canvas: ReturnType<typeof within>,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
await userEvent.click(
|
||||||
|
canvas.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickSortOption(
|
||||||
|
canvas: ReturnType<typeof within>,
|
||||||
|
sortLabel: string,
|
||||||
|
itemLabel?: string
|
||||||
|
) {
|
||||||
|
await userEvent.click(
|
||||||
|
canvas.getByRole('button', { name: new RegExp(`^${sortLabel}`, 'i') })
|
||||||
|
);
|
||||||
|
if (itemLabel) {
|
||||||
|
await userEvent.click(
|
||||||
|
canvas.getByRole('menuitem', { name: new RegExp(itemLabel, 'i') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFilterChecked(canvas: ReturnType<typeof within>, label: string) {
|
||||||
|
expect(
|
||||||
|
canvas.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
|
||||||
|
).toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSortActive(canvas: ReturnType<typeof within>, label: string) {
|
||||||
|
expect(
|
||||||
|
canvas.getByRole('button', { name: new RegExp(`^${label}`, 'i') })
|
||||||
|
).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// This story demonstrates how StatusSummaryBar and SortableList integrate:
|
||||||
|
// the summary bar filter and the group-sort dimension filter share state so
|
||||||
|
// that selecting a status from either control keeps both in sync.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useTableStateFromUrl,
|
||||||
|
asEnum,
|
||||||
|
} from '@@/datatables/useTableStateFromUrl';
|
||||||
|
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
|
||||||
|
import {
|
||||||
|
StatusSummaryBar,
|
||||||
|
StatusSegment,
|
||||||
|
} from '@@/StatusSummaryBar/StatusSummaryBar';
|
||||||
|
|
||||||
|
import { SortableList } from './SortableList';
|
||||||
|
import { SortableGroup } from './SortableListGroup';
|
||||||
|
|
||||||
|
describe('StatusFilteredList', () => {
|
||||||
|
it('Click "Error" in summary bar → list shows only error items, sort unchanged, summary bar highlights "Error"', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
expectDefaultValues();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
|
||||||
|
expectFilterChecked('error');
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expectSortActive('Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Click "Error" again (active) → filter clears, all items shown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
expectDefaultValues();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
|
||||||
|
expectFilterChecked('total');
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Summary bar active, click sort header "Status" → status filter clears', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
await selectSortByFilter(user, 'Status', 'All');
|
||||||
|
|
||||||
|
expectFilterChecked('total');
|
||||||
|
expectSortActive('Status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Summary bar active, click sort header "Type" → status persists', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
await selectSortByFilter(user, 'Type', 'Stack');
|
||||||
|
|
||||||
|
expectFilterChecked('error');
|
||||||
|
expectSortActive('Type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to a non-grouped sort clears a group-set filter', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Error');
|
||||||
|
expectFilterChecked('error');
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Name');
|
||||||
|
|
||||||
|
expectFilterChecked('total');
|
||||||
|
expectSortActive('Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Click "Error" in status group → sort=Status, filter=Error, summary bar also shows "Error"', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Error');
|
||||||
|
|
||||||
|
expectFilterChecked('error');
|
||||||
|
expectSortActive('Status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summary bar filter persists when switching to a different dimension', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
await selectSortByFilter(user, 'Type', 'Stack');
|
||||||
|
|
||||||
|
expectFilterChecked('error');
|
||||||
|
expectSortActive('Type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('second group switch clears old dimension, summary bar filter persists', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'error');
|
||||||
|
await selectSortByFilter(user, 'Type', 'Stack');
|
||||||
|
expectFilterChecked('error');
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Platform', 'Linux');
|
||||||
|
|
||||||
|
expectFilterChecked('error');
|
||||||
|
expectSortActive('Platform');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselecting a group value keeps sort, clears filter', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Error');
|
||||||
|
expectFilterChecked('error');
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'All');
|
||||||
|
|
||||||
|
expectFilterChecked('total');
|
||||||
|
expectSortActive('Status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summary bar overrides a group-set filter', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Error');
|
||||||
|
expectFilterChecked('error');
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'healthy');
|
||||||
|
|
||||||
|
expectFilterChecked('healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group value overrides a summary bar filter; summary bar updates', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'healthy');
|
||||||
|
expectFilterChecked('healthy');
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Outdated');
|
||||||
|
|
||||||
|
expectFilterChecked('outdated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summary bar filter is preserved when switching dimensions after overriding a group-set filter', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Status', 'Error');
|
||||||
|
expectFilterChecked('error');
|
||||||
|
|
||||||
|
await selectSummaryBarOption(user, 'healthy');
|
||||||
|
expectFilterChecked('healthy');
|
||||||
|
|
||||||
|
await selectSortByFilter(user, 'Type', 'Stack');
|
||||||
|
|
||||||
|
expectFilterChecked('healthy');
|
||||||
|
expectSortActive('Type');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
platform: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: Item[] = [
|
||||||
|
{ id: 1, name: 'Alpha', status: 'error', type: 'stack', platform: 'linux' },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Beta',
|
||||||
|
status: 'healthy',
|
||||||
|
type: 'stack',
|
||||||
|
platform: 'linux',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Gamma',
|
||||||
|
status: 'error',
|
||||||
|
type: 'edge',
|
||||||
|
platform: 'windows',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Delta',
|
||||||
|
status: 'healthy',
|
||||||
|
type: 'edge',
|
||||||
|
platform: 'windows',
|
||||||
|
},
|
||||||
|
{ id: 5, name: 'Eta', status: 'outdated', type: 'stack', platform: 'mac' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEGMENTS: StatusSegment<string>[] = [
|
||||||
|
{ key: 'error', label: 'Error', count: 2, color: 'error' },
|
||||||
|
{ key: 'healthy', label: 'Healthy', count: 2, color: 'success' },
|
||||||
|
{ key: 'outdated', label: 'Outdated', count: 1, color: 'warning' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'status', label: 'Status', grouped: true },
|
||||||
|
{ key: 'type', label: 'Type', grouped: true },
|
||||||
|
{ key: 'platform', label: 'Platform', grouped: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_OPTIONS: Record<string, Array<{ key: string; label: string }>> = {
|
||||||
|
status: SEGMENTS,
|
||||||
|
type: [
|
||||||
|
{ key: 'stack', label: 'Stack' },
|
||||||
|
{ key: 'edge', label: 'Edge' },
|
||||||
|
],
|
||||||
|
platform: [
|
||||||
|
{ key: 'linux', label: 'Linux' },
|
||||||
|
{ key: 'windows', label: 'Windows' },
|
||||||
|
{ key: 'mac', label: 'Mac' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_SET = new Set(['error', 'healthy', 'outdated']);
|
||||||
|
const TYPE_SET = new Set(['stack', 'edge']);
|
||||||
|
const PLATFORM_SET = new Set(['linux', 'windows', 'mac']);
|
||||||
|
const SORT_KEYS = ['name', 'status', 'type', 'platform'] as const;
|
||||||
|
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
|
||||||
|
|
||||||
|
function buildGroups(items: Item[], sortBy: string): SortableGroup<Item>[] {
|
||||||
|
const options = GROUP_OPTIONS[sortBy];
|
||||||
|
if (!options) {
|
||||||
|
return items.length > 0 ? [{ key: 'all', label: 'All', items }] : [];
|
||||||
|
}
|
||||||
|
function getField(item: Item) {
|
||||||
|
return item[sortBy as keyof Item] as string;
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
items: items.filter((item) => getField(item) === key),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestList() {
|
||||||
|
const tableState = useTableStateFromUrl({
|
||||||
|
localStorageKey: 'test-status-filtered-list',
|
||||||
|
defaultSort: 'name',
|
||||||
|
parseExtra: (p) => ({
|
||||||
|
status: asEnum(p.status, FILTER_SET),
|
||||||
|
type: asEnum(p.type, TYPE_SET),
|
||||||
|
platform: asEnum(p.platform, PLATFORM_SET),
|
||||||
|
}),
|
||||||
|
buildExtra: (urlState, setUrlState) => ({
|
||||||
|
filterValue: urlState.status,
|
||||||
|
setFilter: (v: string | null) =>
|
||||||
|
setUrlState({
|
||||||
|
status: v,
|
||||||
|
page: 0,
|
||||||
|
...(urlState.sort === 'status' ? { sort: 'name' } : {}),
|
||||||
|
}),
|
||||||
|
...buildGroupSortExtras({
|
||||||
|
urlState,
|
||||||
|
setUrlState,
|
||||||
|
defaultSort: 'name',
|
||||||
|
sortKeys: SORT_KEYS,
|
||||||
|
dimensions: DIMENSIONS,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortBy = tableState.sortBy?.id ?? 'name';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusSummaryBar
|
||||||
|
total={ITEMS.length}
|
||||||
|
segments={SEGMENTS}
|
||||||
|
value={tableState.filterValue}
|
||||||
|
onChange={tableState.setFilter}
|
||||||
|
data-cy="test-list-status-bar"
|
||||||
|
/>
|
||||||
|
<SortableList
|
||||||
|
tableState={tableState}
|
||||||
|
sortOptions={SORT_OPTIONS}
|
||||||
|
groupOptions={GROUP_OPTIONS}
|
||||||
|
groups={buildGroups(ITEMS, sortBy)}
|
||||||
|
totalCount={ITEMS.length}
|
||||||
|
renderItem={(item) => <div>{item.name}</div>}
|
||||||
|
showGroupHeaders
|
||||||
|
data-cy="test-list-list"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
return render(<TestList />);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSummaryBarOption(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSortByFilter(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
sortLabel: string,
|
||||||
|
itemLabel?: string
|
||||||
|
) {
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: new RegExp(`^${sortLabel}`, 'i') })
|
||||||
|
);
|
||||||
|
if (itemLabel) {
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('menuitem', { name: new RegExp(itemLabel, 'i') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFilterChecked(label: string) {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
|
||||||
|
).toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSortActive(label: string) {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: new RegExp(`^${label}`, 'i') })
|
||||||
|
).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectDefaultValues() {
|
||||||
|
expectSortActive('Name');
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@reach/menu-button');
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useParamState', () => ({
|
||||||
|
useParamsState: <T extends Record<string, unknown>>(
|
||||||
|
parseParams: (params: Record<string, string | undefined>) => T
|
||||||
|
) => {
|
||||||
|
const [params, setParams] = useState<Record<string, string | undefined>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
function setState(newState: Partial<T>) {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const [k, v] of Object.entries(newState)) {
|
||||||
|
if (v === null || v === undefined) {
|
||||||
|
delete next[k];
|
||||||
|
} else {
|
||||||
|
next[k] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [parseParams(params), setState] as const;
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -16,7 +16,7 @@ interface SortableListSettings
|
|||||||
groupBy: string | null;
|
groupBy: string | null;
|
||||||
setGroupBy: (group: string | null) => void;
|
setGroupBy: (group: string | null) => void;
|
||||||
groupFilter: string | null;
|
groupFilter: string | null;
|
||||||
setGroupFilter: (group: string | null, filter: string | null) => void;
|
setGroupFilter: (value: { group: string; groupValue: string | null }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortableListState = TableState<SortableListSettings>;
|
export type SortableListState = TableState<SortableListSettings>;
|
||||||
@@ -29,8 +29,23 @@ function sortableListExtras(
|
|||||||
groupBy: null,
|
groupBy: null,
|
||||||
setGroupBy: (group) => set((s) => ({ ...s, groupBy: group })),
|
setGroupBy: (group) => set((s) => ({ ...s, groupBy: group })),
|
||||||
groupFilter: null,
|
groupFilter: null,
|
||||||
setGroupFilter: (group: string | null, filter: string | null) =>
|
setGroupFilter: ({
|
||||||
set((s) => ({ ...s, groupBy: group, groupFilter: filter, page: 0 })),
|
group,
|
||||||
|
groupValue,
|
||||||
|
}: {
|
||||||
|
group: string;
|
||||||
|
groupValue: string | null;
|
||||||
|
}) =>
|
||||||
|
set((s) => ({
|
||||||
|
...s,
|
||||||
|
sortBy: {
|
||||||
|
id: group,
|
||||||
|
desc: s.sortBy?.id === group ? !s.sortBy.desc : false,
|
||||||
|
},
|
||||||
|
groupBy: group,
|
||||||
|
groupFilter: groupValue,
|
||||||
|
page: 0,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface Props {
|
|||||||
export function FilterBarActiveIndicator({ label, onClear }: Props) {
|
export function FilterBarActiveIndicator({ label, onClear }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
className="flex items-center gap-4 whitespace-nowrap bg-gray-2 px-5 th-highcontrast:bg-transparent th-dark:bg-gray-iron-10"
|
className="flex items-center gap-4 whitespace-nowrap bg-gray-2 px-5 th-highcontrast:bg-transparent th-dark:bg-gray-iron-10"
|
||||||
data-cy="active-filter-indicator"
|
data-cy="active-filter-indicator"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Props extends AutomationTestingProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
name: string;
|
name: string;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBarButton({
|
export function FilterBarButton({
|
||||||
@@ -28,9 +29,10 @@ export function FilterBarButton({
|
|||||||
onClick,
|
onClick,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
|
isLoading = false,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (count === 0) {
|
if (!isLoading && count === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +81,11 @@ export function FilterBarButton({
|
|||||||
)}
|
)}
|
||||||
{!colors && (
|
{!colors && (
|
||||||
<span className="flex items-baseline gap-2">
|
<span className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-bold">{count}</span>
|
{isLoading ? (
|
||||||
|
<span className="h-8 w-8 animate-pulse rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold">{count}</span>
|
||||||
|
)}
|
||||||
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
|
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('StatusSummaryBar', () => {
|
|||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderComponent({ value: 'down', onChange });
|
renderComponent({ value: 'down', onChange });
|
||||||
|
|
||||||
expect(screen.getByTestId('active-filter-indicator')).toBeVisible();
|
expect(screen.getByRole('status')).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: /clear filter/i })
|
screen.getByRole('button', { name: /clear filter/i })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Props<TValue> {
|
|||||||
onChange: (filter: TValue | null) => void;
|
onChange: (filter: TValue | null) => void;
|
||||||
radioGroupName?: string;
|
radioGroupName?: string;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
'data-cy'?: string;
|
'data-cy'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function StatusSummaryBar<TValue extends string = string>({
|
|||||||
onChange,
|
onChange,
|
||||||
radioGroupName = 'status-summary-filter',
|
radioGroupName = 'status-summary-filter',
|
||||||
ariaLabel = 'Filter by status',
|
ariaLabel = 'Filter by status',
|
||||||
|
isLoading = false,
|
||||||
'data-cy': dataCy = 'status-summary-bar',
|
'data-cy': dataCy = 'status-summary-bar',
|
||||||
}: Props<TValue>) {
|
}: Props<TValue>) {
|
||||||
const isAllSelected = !value || value === 'all' || value === 'custom';
|
const isAllSelected = !value || value === 'all' || value === 'custom';
|
||||||
@@ -47,6 +49,7 @@ export function StatusSummaryBar<TValue extends string = string>({
|
|||||||
isSelected={isAllSelected}
|
isSelected={isAllSelected}
|
||||||
onClick={() => onChange(null)}
|
onClick={() => onChange(null)}
|
||||||
name={radioGroupName}
|
name={radioGroupName}
|
||||||
|
isLoading={isLoading}
|
||||||
data-cy={`${dataCy}-total`}
|
data-cy={`${dataCy}-total`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildGroupSortExtras } from './groupSortState';
|
||||||
|
|
||||||
|
describe('buildGroupSortExtras — summary bar scenarios', () => {
|
||||||
|
it('switching to status sort clears status set via summary bar', () => {
|
||||||
|
// sort='name', status='error' (set via summary bar) → setSortBy('status')
|
||||||
|
// → status cleared (entering grouped-by-status mode resets the filter)
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
|
||||||
|
extras.setSortBy('status', false);
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'status',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
status: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to a different sort preserves status set via summary bar', () => {
|
||||||
|
// sort='name', status='error' (set via summary bar) → setSortBy('type')
|
||||||
|
// → status persists (cross-dimension, type cleared, status untouched)
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
|
||||||
|
extras.setSortBy('type', false);
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'type',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGroupSortExtras — sort bar group scenarios', () => {
|
||||||
|
it('leaving status sort clears status set via sort bar group', () => {
|
||||||
|
// sort='status', status='error' (set via sort bar group) → setSortBy('name')
|
||||||
|
// → status cleared (leaving grouped-by-status mode)
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
|
||||||
|
extras.setSortBy('name', false);
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
status: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a sort bar group value sets sort and filter together', () => {
|
||||||
|
// sort='name' → setGroupFilter('status', 'error')
|
||||||
|
// → sort=status, status=error
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'name' }, setUrlState);
|
||||||
|
extras.setGroupFilter({ group: 'status', groupValue: 'error' });
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'status',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselecting a sort bar group value keeps sort, clears filter, changes order', () => {
|
||||||
|
// sort='status', status='error' (set via sort bar group) → setGroupFilter('status', null)
|
||||||
|
// → sort stays status, status cleared, order toggles
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras(
|
||||||
|
{ sort: 'status', status: 'error', order: 'asc' },
|
||||||
|
setUrlState
|
||||||
|
);
|
||||||
|
extras.setGroupFilter({ group: 'status', groupValue: null });
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'status',
|
||||||
|
order: 'desc',
|
||||||
|
page: 0,
|
||||||
|
status: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGroupSortExtras — cross-dimension AND filter scenarios', () => {
|
||||||
|
it('status set via summary bar persists when switching to a different sort bar group option', () => {
|
||||||
|
// sort='name', status='error' (set via summary bar) → setGroupFilter('type', 'stack')
|
||||||
|
// → sort=type, type=stack, status=error preserved (AND filter)
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
|
||||||
|
extras.setGroupFilter({ group: 'type', groupValue: 'stack' });
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'type',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
type: 'stack',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status set via summary bar persists through a second sort bar group switch, old group dimension clears', () => {
|
||||||
|
// sort='type', type='stack', status='error' (set via summary bar, AND filter)
|
||||||
|
// → setGroupFilter('platform', 'docker')
|
||||||
|
// → sort=platform, type cleared, status still persists
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtrasWithPlatform(
|
||||||
|
{ sort: 'type', type: 'stack', status: 'error' },
|
||||||
|
setUrlState
|
||||||
|
);
|
||||||
|
extras.setGroupFilter({ group: 'platform', groupValue: 'docker' });
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'platform',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
platform: 'docker',
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status set via sort bar group clears when switching to a different sort bar group option', () => {
|
||||||
|
// sort='status', status='error' (set via sort bar group) → setGroupFilter('type', 'stack')
|
||||||
|
// → status cleared (was the old sort dimension)
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
|
||||||
|
extras.setGroupFilter({ group: 'type', groupValue: 'stack' });
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
sort: 'type',
|
||||||
|
order: 'asc',
|
||||||
|
page: 0,
|
||||||
|
type: 'stack',
|
||||||
|
status: null,
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGroupSortExtras — override scenarios', () => {
|
||||||
|
it('summary bar overrides status set via sort bar group (last write wins)', () => {
|
||||||
|
// setStatus is handled by the view, not buildGroupSortExtras.
|
||||||
|
// Verify that groupFilter reflects the current URL status when sort=status.
|
||||||
|
const extras = makeExtras({ sort: 'status', status: 'healthy' });
|
||||||
|
expect(extras.groupFilter).toBe('healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggling sort direction preserves the active sort bar group filter', () => {
|
||||||
|
const setUrlState = vi.fn();
|
||||||
|
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
|
||||||
|
extras.setSortBy('status', true);
|
||||||
|
expect(setUrlState).toHaveBeenCalledWith({
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
sort: 'status',
|
||||||
|
order: 'desc',
|
||||||
|
page: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeExtras(
|
||||||
|
urlState: {
|
||||||
|
sort: string;
|
||||||
|
status?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
order?: 'desc' | 'asc';
|
||||||
|
},
|
||||||
|
setUrlState = vi.fn()
|
||||||
|
) {
|
||||||
|
const order = urlState.order || 'asc';
|
||||||
|
return buildGroupSortExtras({
|
||||||
|
urlState: { status: null, type: null, order, ...urlState },
|
||||||
|
setUrlState,
|
||||||
|
defaultSort: 'name',
|
||||||
|
sortKeys: ['name', 'status', 'type'] as const,
|
||||||
|
dimensions: [{ key: 'status' }, { key: 'type' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExtrasWithPlatform(
|
||||||
|
urlState: {
|
||||||
|
sort: string;
|
||||||
|
status?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
platform?: string | null;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
},
|
||||||
|
setUrlState = vi.fn()
|
||||||
|
) {
|
||||||
|
const order = urlState.order || 'asc';
|
||||||
|
return buildGroupSortExtras({
|
||||||
|
urlState: { status: null, type: null, platform: null, order, ...urlState },
|
||||||
|
setUrlState,
|
||||||
|
defaultSort: 'name',
|
||||||
|
sortKeys: ['name', 'status', 'type', 'platform'] as const,
|
||||||
|
dimensions: [{ key: 'status' }, { key: 'type' }, { key: 'platform' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
interface DimensionConfig {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UrlState = { sort: string; order: 'asc' | 'desc' } & Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
type SetUrlState = (update: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
interface GroupSortConfig {
|
||||||
|
urlState: UrlState;
|
||||||
|
setUrlState: SetUrlState;
|
||||||
|
defaultSort: string;
|
||||||
|
sortKeys: readonly string[];
|
||||||
|
dimensions: DimensionConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupSortExtras({
|
||||||
|
urlState,
|
||||||
|
setUrlState,
|
||||||
|
defaultSort,
|
||||||
|
sortKeys,
|
||||||
|
dimensions,
|
||||||
|
}: GroupSortConfig) {
|
||||||
|
const sortKey = toSortKey(urlState.sort, sortKeys, defaultSort);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupFilter: dimensions.some((d) => d.key === sortKey)
|
||||||
|
? (urlState[sortKey] as string | null)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
setGroupFilter: ({
|
||||||
|
group,
|
||||||
|
groupValue,
|
||||||
|
}: {
|
||||||
|
group: string;
|
||||||
|
groupValue: string | null;
|
||||||
|
}) => {
|
||||||
|
const newKey = toSortKey(group, sortKeys, defaultSort);
|
||||||
|
const currentKey = toSortKey(urlState.sort, sortKeys, defaultSort);
|
||||||
|
const isLeavingDimension =
|
||||||
|
currentKey !== newKey && dimensions.some((d) => d.key === currentKey);
|
||||||
|
setUrlState({
|
||||||
|
sort: newKey,
|
||||||
|
order: newOrder(sortKey, newKey, urlState.order, groupValue),
|
||||||
|
page: 0,
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
[newKey]: groupValue,
|
||||||
|
...(isLeavingDimension ? { [currentKey]: null } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setSortBy: (id: string, desc: boolean) => {
|
||||||
|
const newKey = toSortKey(id ?? defaultSort, sortKeys, defaultSort);
|
||||||
|
const currentKey = toSortKey(urlState.sort, sortKeys, defaultSort);
|
||||||
|
const dimsToClear =
|
||||||
|
newKey !== currentKey
|
||||||
|
? dimensions.filter((d) => d.key === newKey || d.key === currentKey)
|
||||||
|
: [];
|
||||||
|
setUrlState({
|
||||||
|
sort: newKey,
|
||||||
|
order: desc ? 'desc' : 'asc',
|
||||||
|
page: 0,
|
||||||
|
groupBy: null,
|
||||||
|
groupFilter: null,
|
||||||
|
...Object.fromEntries(dimsToClear.map((d) => [d.key, null])),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSortKey(
|
||||||
|
sort: string,
|
||||||
|
sortKeys: readonly string[],
|
||||||
|
defaultSort: string
|
||||||
|
): string {
|
||||||
|
return sortKeys.includes(sort) ? sort : defaultSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newOrder(
|
||||||
|
currentSortKey: string,
|
||||||
|
newSortKey: string,
|
||||||
|
currentOrder: 'asc' | 'desc',
|
||||||
|
groupValue: string | null = null
|
||||||
|
): 'asc' | 'desc' {
|
||||||
|
if (currentSortKey !== newSortKey) {
|
||||||
|
return 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only toggle sort order when clearing the group filter (groupValue is null)
|
||||||
|
if (groupValue !== null) {
|
||||||
|
return currentOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ type Extra = {
|
|||||||
groupBy: string | null;
|
groupBy: string | null;
|
||||||
setGroupBy(group: string | null): void;
|
setGroupBy(group: string | null): void;
|
||||||
groupFilter: string | null;
|
groupFilter: string | null;
|
||||||
setGroupFilter(group: string | null, filter: string | null): void;
|
setGroupFilter(value: { group: string; groupValue: string | null }): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useTableStateFromUrl<
|
export function useTableStateFromUrl<
|
||||||
@@ -86,10 +86,19 @@ export function useTableStateFromUrl<
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
groupFilter: urlState.groupFilter,
|
groupFilter: urlState.groupFilter,
|
||||||
setGroupFilter: (group, filter) => {
|
setGroupFilter: ({
|
||||||
|
group,
|
||||||
|
groupValue,
|
||||||
|
}: {
|
||||||
|
group: string;
|
||||||
|
groupValue: string | null;
|
||||||
|
}) => {
|
||||||
|
const isSameGroup = urlState.groupBy === group;
|
||||||
setCoreState({
|
setCoreState({
|
||||||
|
sort: group,
|
||||||
|
order: isSameGroup && urlState.order === 'asc' ? 'desc' : 'asc',
|
||||||
groupBy: group,
|
groupBy: group,
|
||||||
groupFilter: filter,
|
groupFilter: groupValue,
|
||||||
page: 0,
|
page: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useHomeViewState() {
|
|||||||
return useTableStateFromUrl<Record<never, never>, Extra>({
|
return useTableStateFromUrl<Record<never, never>, Extra>({
|
||||||
localStorageKey: STORAGE_KEY,
|
localStorageKey: STORAGE_KEY,
|
||||||
defaultSort: DEFAULT_SORT,
|
defaultSort: DEFAULT_SORT,
|
||||||
|
defaultGroupBy: DEFAULT_SORT,
|
||||||
buildExtra: (urlState, setUrlState) => {
|
buildExtra: (urlState, setUrlState) => {
|
||||||
return {
|
return {
|
||||||
groupKey: urlState.groupBy
|
groupKey: urlState.groupBy
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function WorkflowsView() {
|
|||||||
value={tableState.status}
|
value={tableState.status}
|
||||||
onChange={tableState.setStatus}
|
onChange={tableState.setStatus}
|
||||||
radioGroupName="workflows-status"
|
radioGroupName="workflows-status"
|
||||||
|
isLoading={summaryQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<SortableList
|
<SortableList
|
||||||
tableState={tableState}
|
tableState={tableState}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
|
||||||
import {
|
import {
|
||||||
asEnum,
|
asEnum,
|
||||||
useTableStateFromUrl,
|
useTableStateFromUrl,
|
||||||
@@ -21,6 +22,16 @@ const DEPLOYMENT_PLATFORMS = new Set<DeploymentPlatform>([
|
|||||||
'kubernetes',
|
'kubernetes',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SORT_KEYS = [
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'type',
|
||||||
|
'platform',
|
||||||
|
'lastSyncDate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
|
||||||
|
|
||||||
export function useListState() {
|
export function useListState() {
|
||||||
return useTableStateFromUrl({
|
return useTableStateFromUrl({
|
||||||
localStorageKey: 'workflows',
|
localStorageKey: 'workflows',
|
||||||
@@ -30,56 +41,19 @@ export function useListState() {
|
|||||||
type: asEnum(params.type, WORKFLOW_TYPES),
|
type: asEnum(params.type, WORKFLOW_TYPES),
|
||||||
platform: asEnum(params.platform, DEPLOYMENT_PLATFORMS),
|
platform: asEnum(params.platform, DEPLOYMENT_PLATFORMS),
|
||||||
}),
|
}),
|
||||||
buildExtra: (urlState, setUrlState) => {
|
buildExtra: (urlState, setUrlState) => ({
|
||||||
return {
|
status: urlState.status,
|
||||||
status: urlState.status,
|
type: urlState.type,
|
||||||
type: urlState.type,
|
platform: urlState.platform,
|
||||||
platform: urlState.platform,
|
setStatus: (v: WorkflowStatus | null) =>
|
||||||
setStatus: (v: WorkflowStatus | null) =>
|
setUrlState({ status: v, page: 0 }),
|
||||||
setUrlState({ status: v, page: 0 }),
|
...buildGroupSortExtras({
|
||||||
|
urlState,
|
||||||
setGroupFilter: (group: string | null, filter: string | null) => {
|
setUrlState,
|
||||||
if (group === 'status') {
|
defaultSort: DEFAULT_SORT,
|
||||||
setUrlState({
|
sortKeys: SORT_KEYS,
|
||||||
groupBy: group,
|
dimensions: DIMENSIONS,
|
||||||
groupFilter: filter,
|
}),
|
||||||
status: filter as WorkflowStatus | null,
|
}),
|
||||||
type: null,
|
|
||||||
platform: null,
|
|
||||||
page: 0,
|
|
||||||
});
|
|
||||||
} else if (group === 'type') {
|
|
||||||
setUrlState({
|
|
||||||
groupBy: group,
|
|
||||||
groupFilter: filter,
|
|
||||||
type: filter as WorkflowType | null,
|
|
||||||
status: null,
|
|
||||||
platform: null,
|
|
||||||
page: 0,
|
|
||||||
});
|
|
||||||
} else if (group === 'platform') {
|
|
||||||
setUrlState({
|
|
||||||
groupBy: group,
|
|
||||||
groupFilter: filter,
|
|
||||||
platform: filter as DeploymentPlatform | null,
|
|
||||||
type: null,
|
|
||||||
status: null,
|
|
||||||
page: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setSortBy: (id: string, desc: boolean) =>
|
|
||||||
setUrlState({
|
|
||||||
sort: id ?? DEFAULT_SORT,
|
|
||||||
order: desc ? 'desc' : 'asc',
|
|
||||||
groupBy: null,
|
|
||||||
groupFilter: null,
|
|
||||||
status: null,
|
|
||||||
type: null,
|
|
||||||
platform: null,
|
|
||||||
page: 0,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
import {
|
||||||
|
SortableGroup,
|
||||||
|
SortableList,
|
||||||
|
SortOption,
|
||||||
|
} from '@@/SortableList/SortableList';
|
||||||
|
import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar';
|
||||||
|
|
||||||
|
import { useSources } from '../queries/useSources';
|
||||||
|
import { useSourcesSummary } from '../queries/useSourcesSummary';
|
||||||
|
import { Source, SourceStatus, SOURCE_TYPES } from '../types';
|
||||||
|
|
||||||
|
import { SourceCard } from './SourceCard';
|
||||||
|
import { useListState } from './useListState';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Array<{
|
||||||
|
key: SourceStatus;
|
||||||
|
label: string;
|
||||||
|
color: 'error' | 'gray' | 'warning' | 'success';
|
||||||
|
}> = [
|
||||||
|
{ key: 'error', label: 'Error', color: 'error' },
|
||||||
|
{ key: 'paused', label: 'Paused', color: 'gray' },
|
||||||
|
{ key: 'syncing', label: 'Syncing', color: 'warning' },
|
||||||
|
{ key: 'healthy', label: 'Healthy', color: 'success' },
|
||||||
|
{ key: 'unknown', label: 'Unknown', color: 'gray' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_CONFIG = Object.entries(SOURCE_TYPES).map(([key, { label }]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SORT_OPTIONS: SortOption[] = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'status', label: 'Status', grouped: true },
|
||||||
|
{ key: 'type', label: 'Type', grouped: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_OPTIONS = {
|
||||||
|
status: STATUS_CONFIG,
|
||||||
|
type: TYPE_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ListView() {
|
||||||
|
const tableState = useListState();
|
||||||
|
const sortBy = tableState.sortBy?.id ?? 'name';
|
||||||
|
|
||||||
|
const sourcesQuery = useSources({
|
||||||
|
search: tableState.search || undefined,
|
||||||
|
sort: sortBy,
|
||||||
|
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
||||||
|
start: tableState.page * tableState.pageSize,
|
||||||
|
limit: tableState.pageSize,
|
||||||
|
status: tableState.status ?? undefined,
|
||||||
|
type: tableState.type ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryQuery = useSourcesSummary();
|
||||||
|
|
||||||
|
const page = sourcesQuery.data?.data;
|
||||||
|
const totalCount = sourcesQuery.data?.totalCount ?? 0;
|
||||||
|
const groups = buildGroups(page, sortBy);
|
||||||
|
|
||||||
|
const statusSegments = STATUS_CONFIG.map((s) => ({
|
||||||
|
...s,
|
||||||
|
count: summaryQuery.data?.[s.key] ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summaryTotal = summaryQuery.data
|
||||||
|
? Object.values(summaryQuery.data).reduce((a, b) => a + b, 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="GitOps Sources" breadcrumbs="GitOps Sources" reload />
|
||||||
|
<div className="mx-4 mb-4 space-y-4">
|
||||||
|
<StatusSummaryBar
|
||||||
|
total={summaryTotal}
|
||||||
|
segments={statusSegments}
|
||||||
|
value={tableState.status}
|
||||||
|
onChange={tableState.setStatus}
|
||||||
|
radioGroupName="sources-status"
|
||||||
|
isLoading={summaryQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<SortableList
|
||||||
|
tableState={tableState}
|
||||||
|
sortOptions={SORT_OPTIONS}
|
||||||
|
groupOptions={{ ...GROUP_OPTIONS, status: statusSegments }}
|
||||||
|
groups={groups}
|
||||||
|
totalCount={totalCount}
|
||||||
|
isLoading={sourcesQuery.isLoading}
|
||||||
|
getItemKey={(item) => item.id}
|
||||||
|
showGroupHeaders
|
||||||
|
emptyMessage="No sources found"
|
||||||
|
searchPlaceholder="Search"
|
||||||
|
renderItem={(item) => <SourceCard item={item} />}
|
||||||
|
data-cy="sources-list"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroups(
|
||||||
|
items: Source[] | null | undefined,
|
||||||
|
sortBy: string
|
||||||
|
): SortableGroup<Source>[] {
|
||||||
|
if (!items?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === 'status') {
|
||||||
|
return STATUS_CONFIG.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
items: items.filter((item) => item.status === key),
|
||||||
|
})).filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === 'type') {
|
||||||
|
return TYPE_CONFIG.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
items: items.filter((item) => item.type === key),
|
||||||
|
})).filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ key: 'all', label: 'All', items }];
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AlertTriangle, GitCommit, Server, WatchIcon } from 'lucide-react';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
import { SortableListItem } from '@@/SortableList/SortableListItem';
|
||||||
|
|
||||||
|
import { StatusBadge } from '../../WorkflowsView/WorkflowBadges';
|
||||||
|
import { Source, SOURCE_TYPES } from '../types';
|
||||||
|
|
||||||
|
import { StatBlock } from './StatBlock';
|
||||||
|
|
||||||
|
export function SourceCard({ item }: { item: Source }) {
|
||||||
|
const { icon: TypeIcon } = SOURCE_TYPES[item.type];
|
||||||
|
const lastSyncLabel = item.lastSync
|
||||||
|
? moment.unix(item.lastSync).fromNow()
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableListItem>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-gray-2 th-highcontrast:bg-gray-8 th-dark:bg-gray-8">
|
||||||
|
<Icon icon={TypeIcon} size="md" className="text-gray-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-semibold tracking-wide text-gray-9 th-highcontrast:text-white th-dark:text-white">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
|
||||||
|
{item.url}
|
||||||
|
</span>
|
||||||
|
{item.error && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-error-8">
|
||||||
|
<Icon icon={AlertTriangle} size="sm" className="shrink-0" />
|
||||||
|
{item.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<StatBlock icon={GitCommit} label="Workflows" value={item.usedBy} />
|
||||||
|
<StatBlock
|
||||||
|
icon={Server}
|
||||||
|
label="Environments"
|
||||||
|
value={item.environments}
|
||||||
|
/>
|
||||||
|
<StatBlock
|
||||||
|
icon={WatchIcon}
|
||||||
|
label="Last sync"
|
||||||
|
value={lastSyncLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatBlock({ icon, label, value }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-[100px] flex-col gap-1 rounded-lg border border-solid border-gray-5 bg-gray-2 px-4 py-3 th-highcontrast:border-white th-highcontrast:bg-gray-10 th-dark:border-gray-8 th-dark:bg-gray-iron-10">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wider text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
|
||||||
|
<Icon icon={icon} size="sm" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
|
||||||
|
import {
|
||||||
|
asEnum,
|
||||||
|
useTableStateFromUrl,
|
||||||
|
} from '@@/datatables/useTableStateFromUrl';
|
||||||
|
|
||||||
|
import { SourceStatus, SourceType } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_SORT = 'name' as const;
|
||||||
|
|
||||||
|
const SOURCE_STATUSES = new Set<SourceStatus>([
|
||||||
|
'healthy',
|
||||||
|
'error',
|
||||||
|
'syncing',
|
||||||
|
'paused',
|
||||||
|
'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SOURCE_TYPES = new Set<SourceType>(['git', 'helm', 'oci']);
|
||||||
|
|
||||||
|
const SORT_KEYS = ['name', 'status', 'type'] as const;
|
||||||
|
|
||||||
|
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }];
|
||||||
|
|
||||||
|
export function useListState() {
|
||||||
|
return useTableStateFromUrl({
|
||||||
|
localStorageKey: 'sources',
|
||||||
|
defaultSort: DEFAULT_SORT,
|
||||||
|
parseExtra: (params) => ({
|
||||||
|
status: asEnum(params.status, SOURCE_STATUSES),
|
||||||
|
type: asEnum(params.type, SOURCE_TYPES),
|
||||||
|
}),
|
||||||
|
buildExtra: (urlState, setUrlState) => ({
|
||||||
|
status: urlState.status,
|
||||||
|
type: urlState.type,
|
||||||
|
setStatus: (v: SourceStatus | null) =>
|
||||||
|
setUrlState({ status: v, page: 0 }),
|
||||||
|
setType: (v: SourceType | null) => setUrlState({ type: v, page: 0 }),
|
||||||
|
...buildGroupSortExtras({
|
||||||
|
urlState,
|
||||||
|
setUrlState,
|
||||||
|
defaultSort: DEFAULT_SORT,
|
||||||
|
sortKeys: SORT_KEYS,
|
||||||
|
dimensions: DIMENSIONS,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const sourceQueryKeys = {
|
||||||
|
all: ['gitops', 'sources'] as const,
|
||||||
|
list: (params: object) => [...sourceQueryKeys.all, 'list', params] as const,
|
||||||
|
summary: () => [...sourceQueryKeys.all, 'summary'] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios from '@/portainer/services/axios/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
import { withPaginationHeaders } from '@/react/common/api/pagination.types';
|
||||||
|
|
||||||
|
import { Source, SourceStatus, SourceType } from '../types';
|
||||||
|
|
||||||
|
import { sourceQueryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export interface SourcesParams {
|
||||||
|
search?: string;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
start?: number;
|
||||||
|
limit?: number;
|
||||||
|
status?: SourceStatus | null;
|
||||||
|
type?: SourceType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSources(params: SourcesParams) {
|
||||||
|
const response = await axios.get<Source[]>('/gitops/sources', { params });
|
||||||
|
return withPaginationHeaders(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSources(params: SourcesParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: sourceQueryKeys.list(params),
|
||||||
|
queryFn: () => getSources(params),
|
||||||
|
...withError('Failed loading sources'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios from '@/portainer/services/axios/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { SourceStatus } from '../types';
|
||||||
|
|
||||||
|
import { sourceQueryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export type SourcesSummary = Record<SourceStatus, number>;
|
||||||
|
|
||||||
|
async function getSourcesSummary(): Promise<SourcesSummary> {
|
||||||
|
const { data } = await axios.get<SourcesSummary>('/gitops/sources/summary');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSourcesSummary() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: sourceQueryKeys.summary(),
|
||||||
|
queryFn: getSourcesSummary,
|
||||||
|
...withError('Failed loading sources summary'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type LucideIcon, Box, GitBranch, Package } from 'lucide-react';
|
||||||
|
|
||||||
|
export type SourceStatus =
|
||||||
|
| 'healthy'
|
||||||
|
| 'error'
|
||||||
|
| 'syncing'
|
||||||
|
| 'paused'
|
||||||
|
| 'unknown';
|
||||||
|
export type SourceType = 'git' | 'helm' | 'oci';
|
||||||
|
|
||||||
|
export interface Source {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: SourceType;
|
||||||
|
url: string;
|
||||||
|
status: SourceStatus;
|
||||||
|
error?: string;
|
||||||
|
provider?: string;
|
||||||
|
usedBy: number;
|
||||||
|
environments: number;
|
||||||
|
lastSync: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SOURCE_TYPES: Record<
|
||||||
|
SourceType,
|
||||||
|
{ label: string; icon: LucideIcon }
|
||||||
|
> = {
|
||||||
|
git: { label: 'Git', icon: GitBranch },
|
||||||
|
helm: { label: 'Helm', icon: Package },
|
||||||
|
oci: { label: 'OCI', icon: Box },
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Database, GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
|
import { SidebarItem } from './SidebarItem';
|
||||||
|
import { SidebarSection } from './SidebarSection';
|
||||||
|
|
||||||
|
export function AppDeliverySidebar({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
return (
|
||||||
|
<SidebarSection title="App Delivery">
|
||||||
|
<SidebarItem
|
||||||
|
label="Workflows"
|
||||||
|
to="portainer.gitops.workflows"
|
||||||
|
icon={GitBranch}
|
||||||
|
data-cy="portainerSidebar-workflows"
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<SidebarItem
|
||||||
|
label="Sources"
|
||||||
|
to="portainer.gitops.sources"
|
||||||
|
icon={Database}
|
||||||
|
data-cy="portainerSidebar-sources"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SidebarSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { GitBranch, Home } from 'lucide-react';
|
import { Home } from 'lucide-react';
|
||||||
|
|
||||||
import { useIsEdgeAdmin, useIsPureAdmin } from '@/react/hooks/useUser';
|
import { useIsEdgeAdmin, useIsPureAdmin } from '@/react/hooks/useUser';
|
||||||
import { useIsCurrentUserTeamLeader } from '@/portainer/users/queries';
|
import { useIsCurrentUserTeamLeader } from '@/portainer/users/queries';
|
||||||
@@ -14,6 +14,7 @@ import { Footer } from './Footer';
|
|||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { SidebarProvider, useSidebarState } from './useSidebarState';
|
import { SidebarProvider, useSidebarState } from './useSidebarState';
|
||||||
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
|
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
|
||||||
|
import { AppDeliverySidebar } from './AppDeliverySidebar';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
return (
|
||||||
@@ -70,12 +71,7 @@ function InnerSidebar() {
|
|||||||
|
|
||||||
<EnvironmentSidebar />
|
<EnvironmentSidebar />
|
||||||
|
|
||||||
<SidebarItem
|
<AppDeliverySidebar isAdmin={isAdmin} />
|
||||||
to="portainer.workflows"
|
|
||||||
icon={GitBranch}
|
|
||||||
label="Workflows"
|
|
||||||
data-cy="portainerSidebar-workflows"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isAdmin && <EdgeComputeSidebar />}
|
{isAdmin && <EdgeComputeSidebar />}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -140,7 +140,8 @@ export default defineConfig([
|
|||||||
{
|
{
|
||||||
object: 'navigator',
|
object: 'navigator',
|
||||||
property: 'clipboard',
|
property: 'clipboard',
|
||||||
message: 'navigator.clipboard requires a secure context (HTTPS). Use the `useCopy` hook or `CopyButton` component (@@/buttons/CopyButton) — they include a non-secure fallback.',
|
message:
|
||||||
|
'navigator.clipboard requires a secure context (HTTPS). Use the `useCopy` hook or `CopyButton` component (@@/buttons/CopyButton) — they include a non-secure fallback.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
object: 'navigator',
|
object: 'navigator',
|
||||||
@@ -263,6 +264,7 @@ export default defineConfig([
|
|||||||
'no-empty-function': 'off',
|
'no-empty-function': 'off',
|
||||||
// Tests mock secure-context APIs directly — the restriction is for production code only
|
// Tests mock secure-context APIs directly — the restriction is for production code only
|
||||||
'no-restricted-properties': 'off',
|
'no-restricted-properties': 'off',
|
||||||
|
'vitest/expect-expect': ['warn', { assertFunctionNames: ['expect*', 'assert*', 'verify*'] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user