Skip to content

Commit 47e16fd

Browse files
authored
feat(groups): Add Groups list page (#888)
Signed-off-by: Bryan Ramos <bramos@redhat.com>
1 parent 08dab12 commit 47e16fd

File tree

12 files changed

+777
-0
lines changed

12 files changed

+777
-0
lines changed

client/src/app/Constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const TablePersistenceKeyPrefixes = {
1616
advisories: "ad",
1717
vulnerabilities: "vn",
1818
sboms: "sb",
19+
sbomGroups: "sbg",
1920
sboms_by_package: "sbk",
2021
packages: "pk",
2122
licenses: "li",
@@ -63,6 +64,13 @@ export const advisoryDeleteDialogProps = (
6364
message: `This action permanently deletes the ${advisory?.document_id} Advisory.`,
6465
});
6566

67+
export const childGroupDeleteDialogProps = (
68+
childGroup?: { name?: string } | null,
69+
) => ({
70+
title: "Permanently delete Group?",
71+
message: `This action permanently deletes the ${childGroup?.name} group.`,
72+
});
73+
6674
export const sbomDeletedSuccessMessage = (sbom: SbomSummary) =>
6775
`The SBOM ${sbom.name} was deleted`;
6876

client/src/app/Routes.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const SBOMList = lazy(() => import("./pages/sbom-list"));
3434
const SBOMUpload = lazy(() => import("./pages/sbom-upload"));
3535
const SBOMScan = lazy(() => import("./pages/sbom-scan"));
3636
const SBOMDetails = lazy(() => import("./pages/sbom-details"));
37+
const SbomGroups = lazy(() => import("./pages/sbom-groups"));
3738

3839
// Others
3940
const Search = lazy(() => import("./pages/search"));
@@ -56,6 +57,7 @@ export const Paths = {
5657
vulnerabilities: "/vulnerabilities",
5758
vulnerabilityDetails: `/vulnerabilities/:${PathParam.VULNERABILITY_ID}`,
5859
sboms: "/sboms",
60+
sbomGroups: "/sboms/groups",
5961
sbomUpload: "/sboms/upload",
6062
sbomScan: "/sboms/scan",
6163
sbomDetails: `/sboms/:${PathParam.SBOM_ID}`,
@@ -177,6 +179,15 @@ export const AppRoutes = createBrowserRouter([
177179
<LazyRouteElement identifier="sbom-list" component={<SBOMList />} />
178180
),
179181
},
182+
{
183+
path: Paths.sbomGroups,
184+
element: (
185+
<LazyRouteElement
186+
identifier="sbom-groups"
187+
component={<SbomGroups />}
188+
/>
189+
),
190+
},
180191
{
181192
path: Paths.sbomDetails,
182193
element: (

client/src/app/layout/sidebar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ export const SidebarApp: React.FC = () => {
5454
All SBOMs
5555
</NavLink>
5656
</li>
57+
<li className={nav.navItem}>
58+
<NavLink
59+
to={Paths.sbomGroups}
60+
className={({ isActive }) => {
61+
return css(LINK_CLASS, isActive ? ACTIVE_LINK_CLASS : "");
62+
}}
63+
>
64+
Groups
65+
</NavLink>
66+
</li>
5767
</NavExpandable>
5868
<li className={nav.navItem}>
5969
<NavLink
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SbomGroups as default } from "./sbom-groups";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Label, LabelGroup } from "@patternfly/react-core";
2+
type Props = {
3+
labels?: Record<string, string | null>;
4+
};
5+
6+
type FormattedLabel = {
7+
text: string;
8+
color: "purple" | "blue";
9+
};
10+
11+
function formatLabel(key: string, value: string | null): FormattedLabel {
12+
const text = value ? `${key}=${value}` : `${key}`;
13+
const color = key === "Product" ? "purple" : "blue";
14+
return { color, text };
15+
}
16+
17+
export const SbomGroupLabels = ({ labels }: Props) => {
18+
if (!labels || Object.keys(labels).length === 0) {
19+
return null;
20+
}
21+
22+
return (
23+
<LabelGroup>
24+
{Object.entries(labels)
25+
.sort(([a], [b]) => (a === "Product" ? -1 : b === "Product" ? 1 : 0))
26+
.map(([key, value]) => {
27+
const { color, text } = formatLabel(key, value);
28+
return (
29+
<Label key={key} color={color}>
30+
{text}
31+
</Label>
32+
);
33+
})}
34+
</LabelGroup>
35+
);
36+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
Stack,
3+
StackItem,
4+
Flex,
5+
FlexItem,
6+
Content,
7+
} from "@patternfly/react-core";
8+
import { NavLink } from "react-router-dom";
9+
import { SbomGroupLabels } from "./sbom-group-labels";
10+
import type { SbomGroupTreeNode } from "./sbom-groups-context";
11+
export const SbomGroupTableData = ({ item }: { item: SbomGroupTreeNode }) => {
12+
return (
13+
<Stack hasGutter>
14+
<StackItem isFilled>
15+
<Flex
16+
alignItems={{ default: "alignItemsCenter" }}
17+
gap={{ default: "gapSm" }}
18+
flexWrap={{ default: "wrap" }}
19+
>
20+
<FlexItem>
21+
<NavLink
22+
className="pf-v6-c-button pf-m-link pf-m-inline"
23+
to={"https://example.com"}
24+
>
25+
{item.name}
26+
</NavLink>
27+
</FlexItem>
28+
<FlexItem>
29+
<SbomGroupLabels labels={item.labels} />
30+
</FlexItem>
31+
</Flex>
32+
</StackItem>
33+
{item.description && (
34+
<StackItem>
35+
<Content component="p">{item.description}</Content>
36+
</StackItem>
37+
)}
38+
{item.number_of_sboms != null && item.number_of_sboms > 0 && (
39+
<StackItem>
40+
<Content component="small">{item.number_of_sboms} SBOMs</Content>
41+
</StackItem>
42+
)}
43+
</Stack>
44+
);
45+
};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import React from "react";
2+
import type { AxiosError } from "axios";
3+
import {
4+
FILTER_TEXT_CATEGORY_KEY,
5+
TablePersistenceKeyPrefixes,
6+
} from "@app/Constants";
7+
import { FilterType } from "@app/components/FilterToolbar";
8+
import {
9+
getHubRequestParams,
10+
type ITableControls,
11+
useTableControlProps,
12+
useTableControlState,
13+
} from "@app/hooks/table-controls";
14+
import { useSelectionState } from "@app/hooks/useSelectionState";
15+
16+
import type { PaginatedResultsGroupDetails } from "@app/client";
17+
import {
18+
useFetchSbomGroupChildren,
19+
useFetchSbomGroups,
20+
} from "@app/queries/sbom-groups";
21+
import { buildSbomGroupTree } from "./utils";
22+
23+
export type SbomGroupItem = PaginatedResultsGroupDetails["items"][number];
24+
25+
export type SbomGroupTreeNode = SbomGroupItem & {
26+
children: SbomGroupTreeNode[];
27+
};
28+
29+
interface ITreeExpansionState {
30+
expandedNodeIds: string[];
31+
setExpandedNodeIds: React.Dispatch<React.SetStateAction<string[]>>;
32+
childrenNodeStatus: Map<
33+
string,
34+
{ isFetching: boolean; fetchError: AxiosError | null }
35+
>;
36+
}
37+
38+
interface ITreeSelectionState {
39+
selectedNodes: SbomGroupItem[];
40+
isNodeSelected(node: SbomGroupTreeNode): boolean;
41+
areAllSelected: boolean;
42+
selectNodes: (nodes: SbomGroupTreeNode[], isSelected: boolean) => void;
43+
selectOnlyNodes: (nodes: SbomGroupTreeNode[]) => void;
44+
selectAllNodes: (isSelected: boolean) => void;
45+
}
46+
47+
interface ISbomGroupsContext {
48+
tableControls: ITableControls<
49+
SbomGroupTreeNode,
50+
// Column keys
51+
"name",
52+
// Sortable column keys
53+
"name",
54+
// Filter categories
55+
"",
56+
// Persistence key prefix
57+
string
58+
>;
59+
60+
totalItemCount: number;
61+
isFetching: boolean;
62+
fetchError: AxiosError | null;
63+
64+
// Tree fields
65+
treeExpansion: ITreeExpansionState;
66+
treeSelection: ITreeSelectionState;
67+
treeData: SbomGroupTreeNode[];
68+
}
69+
70+
const contextDefaultValue = {} as ISbomGroupsContext;
71+
72+
export const SbomGroupsContext =
73+
React.createContext<ISbomGroupsContext>(contextDefaultValue);
74+
75+
interface ISbomGroupsProvider {
76+
children: React.ReactNode;
77+
}
78+
79+
export const SbomGroupsProvider: React.FunctionComponent<
80+
ISbomGroupsProvider
81+
> = ({ children }) => {
82+
const tableControlState = useTableControlState({
83+
tableName: "sbom-groups",
84+
persistenceKeyPrefix: TablePersistenceKeyPrefixes.sbomGroups,
85+
persistTo: "urlParams",
86+
columnNames: {
87+
name: "name",
88+
},
89+
isPaginationEnabled: true,
90+
isSortEnabled: true,
91+
sortableColumns: ["name"],
92+
isFilterEnabled: true,
93+
filterCategories: [
94+
{
95+
categoryKey: FILTER_TEXT_CATEGORY_KEY,
96+
title: "Filter",
97+
placeholderText: "Search",
98+
type: FilterType.search,
99+
},
100+
],
101+
isExpansionEnabled: true,
102+
expandableVariant: "single",
103+
});
104+
105+
// Expansion state stored in React state (transient UI state, not URL-worthy)
106+
const [expandedNodeIds, setExpandedNodeIds] = React.useState<string[]>([]);
107+
108+
// Fetch paginated root groups (parent IS NULL)
109+
const {
110+
result: { data: rootGroups, total: totalItemCount },
111+
isFetching: isRootsFetching,
112+
fetchError,
113+
} = useFetchSbomGroups(
114+
undefined,
115+
getHubRequestParams({
116+
...tableControlState,
117+
hubSortFieldKeys: {
118+
name: "name",
119+
},
120+
}),
121+
);
122+
123+
// Fetch children for all expanded groups
124+
const { data: childGroups, nodeStatus: childrenNodeStatus } =
125+
useFetchSbomGroupChildren(expandedNodeIds);
126+
127+
// Merge root groups + children into a flat list, then build tree
128+
const allGroups = React.useMemo(
129+
() => [...rootGroups, ...childGroups],
130+
[rootGroups, childGroups],
131+
);
132+
133+
const roots = React.useMemo(() => {
134+
return buildSbomGroupTree(allGroups);
135+
}, [allGroups]);
136+
137+
// Only use root-fetching for the table loading state.
138+
const isFetching = isRootsFetching;
139+
140+
const {
141+
selectedItems: selectedNodes,
142+
isItemSelected: isNodeSelected,
143+
areAllSelected,
144+
selectItems: selectNodes,
145+
selectOnly: selectOnlyNodes,
146+
selectAll: selectAllNodes,
147+
} = useSelectionState({
148+
items: allGroups,
149+
isEqual: (a, b) => a.id === b.id,
150+
});
151+
152+
const tableControls = useTableControlProps({
153+
...tableControlState,
154+
idProperty: "id",
155+
currentPageItems: roots,
156+
totalItemCount,
157+
isLoading: isFetching,
158+
hasActionsColumn: true,
159+
});
160+
161+
return (
162+
<SbomGroupsContext.Provider
163+
value={{
164+
tableControls,
165+
isFetching,
166+
fetchError,
167+
totalItemCount,
168+
treeExpansion: {
169+
expandedNodeIds,
170+
setExpandedNodeIds,
171+
childrenNodeStatus,
172+
},
173+
treeSelection: {
174+
selectedNodes,
175+
isNodeSelected,
176+
areAllSelected,
177+
selectNodes,
178+
selectOnlyNodes,
179+
selectAllNodes,
180+
},
181+
treeData: roots,
182+
}}
183+
>
184+
{children}
185+
</SbomGroupsContext.Provider>
186+
);
187+
};

0 commit comments

Comments
 (0)