Skip to content

Commit ecec09f

Browse files
committed
feat: add taxonomy to service catalog services page
1 parent 85e2d7f commit ecec09f

File tree

14 files changed

+343
-184
lines changed

14 files changed

+343
-184
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type React from "react";
2+
import { Grid } from "@zendeskgarden/react-grid";
3+
import { Skeleton } from "@zendeskgarden/react-loaders";
4+
import styled from "styled-components";
5+
6+
const SidebarContainer = styled.div`
7+
width: 250px;
8+
flex-shrink: 0;
9+
display: flex;
10+
flex-direction: column;
11+
gap: 8px;
12+
`;
13+
14+
const ContentContainer = styled.div`
15+
flex: 1;
16+
display: flex;
17+
flex-direction: column;
18+
gap: ${(props) => `${props.theme.space.base * 6}px`};
19+
`;
20+
21+
const StyledGrid = styled(Grid)`
22+
padding: 0;
23+
`;
24+
25+
const StyledCol = styled(Grid.Col)`
26+
@media (min-width: 0px) {
27+
margin-bottom: ${(props) => props.theme.space.md};
28+
}
29+
`;
30+
31+
const SidebarSkeleton = () => (
32+
<SidebarContainer>
33+
<Skeleton width="100%" height="40px" />
34+
<Skeleton width="100%" height="40px" />
35+
<Skeleton width="100%" height="40px" />
36+
<Skeleton width="80%" height="40px" />
37+
<Skeleton width="80%" height="40px" />
38+
<Skeleton width="100%" height="40px" />
39+
</SidebarContainer>
40+
);
41+
42+
const ContentSkeleton = () => (
43+
<ContentContainer>
44+
<Skeleton width="200px" height="28px" />
45+
<Skeleton width="320px" height="40px" />
46+
<Skeleton width="120px" height="20px" />
47+
<StyledGrid>
48+
<Grid.Row wrap="wrap">
49+
<StyledCol xs={12} sm={6} md={4} lg={3}>
50+
<Skeleton width="100%" height="140px" />
51+
</StyledCol>
52+
<StyledCol xs={12} sm={6} md={4} lg={3}>
53+
<Skeleton width="100%" height="140px" />
54+
</StyledCol>
55+
<StyledCol xs={12} sm={6} md={4} lg={3}>
56+
<Skeleton width="100%" height="140px" />
57+
</StyledCol>
58+
<StyledCol xs={12} sm={6} md={4} lg={3}>
59+
<Skeleton width="100%" height="140px" />
60+
</StyledCol>
61+
</Grid.Row>
62+
</StyledGrid>
63+
</ContentContainer>
64+
);
65+
66+
export const PageLoadingState: React.FC = () => (
67+
<>
68+
<aside className="service-catalog-sidebar">
69+
<SidebarSkeleton />
70+
</aside>
71+
<main className="service-catalog-list">
72+
<ContentSkeleton />
73+
</main>
74+
</>
75+
);

src/modules/service-catalog/components/ServiceCatalogPage.tsx

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,74 @@
11
import type React from "react";
2-
import { useState } from "react";
2+
import { useCallback, useMemo, useState } from "react";
33
import { ServiceCatalogCategoriesSidebar } from "../components/service-catalog-categories-sidebar";
44
import { ServiceCatalogList } from "./service-catalog-list/ServiceCatalogList";
5-
import { useServiceCatalogCategoryTree } from "../hooks/useServiceCatalogCategoryTree";
5+
import type { ServiceCatalogCategory } from "../data-types/ServiceCatalogCategory";
6+
import {
7+
ALL_SERVICES_ID,
8+
UNCATEGORIZED_ID,
9+
} from "./service-catalog-categories-sidebar/constants";
610

7-
export const ServiceCatalogPage: React.FC<{ helpCenterPath: string }> = ({
11+
function getInitialCategoryId(): string | null {
12+
const params = new URLSearchParams(window.location.search);
13+
return params.get("category_id");
14+
}
15+
16+
function findCategoryById(
17+
categories: ServiceCatalogCategory[],
18+
id: string
19+
): ServiceCatalogCategory | null {
20+
for (const category of categories) {
21+
if (category.id === id) return category;
22+
if (category.children.length) {
23+
const found = findCategoryById(category.children, id);
24+
if (found) return found;
25+
}
26+
}
27+
return null;
28+
}
29+
30+
interface ServiceCatalogPageProps {
31+
helpCenterPath: string;
32+
categoryTree: ServiceCatalogCategory[];
33+
}
34+
35+
export const ServiceCatalogPage: React.FC<ServiceCatalogPageProps> = ({
836
helpCenterPath,
37+
categoryTree,
938
}) => {
10-
const { categoryTree, isLoading, error } = useServiceCatalogCategoryTree();
11-
const hasCategories = Array.isArray(categoryTree) && categoryTree.length > 0;
39+
const hasCategories = categoryTree.length > 0;
1240
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
13-
null
41+
getInitialCategoryId
1442
);
1543

44+
const selectedCategoryName = useMemo(() => {
45+
if (!selectedCategoryId || !hasCategories) return null;
46+
if (selectedCategoryId === ALL_SERVICES_ID) return ALL_SERVICES_ID;
47+
if (selectedCategoryId === UNCATEGORIZED_ID) return UNCATEGORIZED_ID;
48+
const category = findCategoryById(categoryTree, selectedCategoryId);
49+
return category?.name ?? null;
50+
}, [selectedCategoryId, categoryTree, hasCategories]);
51+
52+
const handleCategorySelect = useCallback((categoryId: string) => {
53+
setSelectedCategoryId(categoryId);
54+
}, []);
55+
1656
return (
1757
<>
1858
{hasCategories && (
1959
<aside className="service-catalog-sidebar">
2060
<ServiceCatalogCategoriesSidebar
21-
categories={categoryTree!}
22-
isLoading={isLoading}
23-
error={error}
61+
categories={categoryTree}
2462
selectedCategoryId={selectedCategoryId}
25-
onSelect={(c) => setSelectedCategoryId(c)}
63+
onSelect={handleCategorySelect}
2664
/>
2765
</aside>
2866
)}
2967
<main className="service-catalog-list">
3068
<ServiceCatalogList
3169
helpCenterPath={helpCenterPath}
3270
selectedCategoryId={hasCategories ? selectedCategoryId : null}
71+
selectedCategoryName={selectedCategoryName}
3372
/>
3473
</main>
3574
</>

src/modules/service-catalog/components/service-catalog-categories-sidebar/CategoryItem.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ export const CategoryItem: React.FC<CategoryItemProps> = ({
140140
const { t } = useTranslation();
141141

142142
const displayName = isAllServices
143-
? t("service-catalog-sidebar.all-services")
143+
? t("service-catalog-sidebar.all-services", "All services")
144144
: isUncategorized
145-
? t("service-catalog-sidebar.uncategorized")
145+
? t("service-catalog-sidebar.uncategorized", "Uncategorized")
146146
: category.name;
147147

148148
const handleExpandClick = (e: React.MouseEvent) => {
@@ -178,8 +178,14 @@ export const CategoryItem: React.FC<CategoryItemProps> = ({
178178
onClick={handleExpandClick}
179179
aria-label={
180180
isExpanded
181-
? t("service-catalog-sidebar.collapse-category")
182-
: t("service-catalog-sidebar.expand-category")
181+
? t(
182+
"service-catalog-sidebar.collapse-category",
183+
"Collapse category"
184+
)
185+
: t(
186+
"service-catalog-sidebar.expand-category",
187+
"Expand category"
188+
)
183189
}
184190
type="button"
185191
>
@@ -195,7 +201,7 @@ export const CategoryItem: React.FC<CategoryItemProps> = ({
195201
</CategoryItemWrapper>
196202
{hasChildren && isExpanded && (
197203
<>
198-
{category.children!.map((child) => (
204+
{category.children?.map((child) => (
199205
<CategoryItem
200206
key={child.id}
201207
category={child}

src/modules/service-catalog/components/service-catalog-categories-sidebar/ServiceCatalogCategoriesSidebar.tsx

Lines changed: 72 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,45 @@
11
import type React from "react";
2-
import { useState, useEffect } from "react";
2+
import { useState, useEffect, useCallback } from "react";
33
import styled from "styled-components";
4-
import { Spinner } from "@zendeskgarden/react-loaders";
54
import { CategoryItem } from "./CategoryItem";
65
import type { Category } from "../../data-types/Categories";
76

7+
function findAncestorIds(
8+
categories: Category[],
9+
targetId: string
10+
): string[] | null {
11+
for (const category of categories) {
12+
if (category.id === targetId) {
13+
return [];
14+
}
15+
if (category.children?.length) {
16+
const path = findAncestorIds(category.children, targetId);
17+
if (path !== null) {
18+
return [category.id, ...path];
19+
}
20+
}
21+
}
22+
return null;
23+
}
24+
825
const Container = styled.div`
926
width: 250px;
1027
`;
1128

12-
const LoadingContainer = styled.div`
13-
display: flex;
14-
justify-content: center;
15-
align-items: center;
16-
height: 100%;
17-
`;
18-
1929
interface ServiceCatalogCategoriesSidebarProps {
2030
categories: Category[];
2131
selectedCategoryId: string | null;
2232
onSelect: (categoryId: string) => void;
23-
isLoading: boolean;
24-
error: unknown;
2533
}
2634

2735
export const ServiceCatalogCategoriesSidebar: React.FC<
2836
ServiceCatalogCategoriesSidebarProps
29-
> = ({ categories, selectedCategoryId, onSelect, isLoading, error }) => {
37+
> = ({ categories, selectedCategoryId, onSelect }) => {
3038
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
3139
new Set()
3240
);
3341

34-
const handleToggleExpand = (categoryId: string) => {
42+
const handleToggleExpand = useCallback((categoryId: string) => {
3543
setExpandedCategories((prev) => {
3644
const next = new Set(prev);
3745
if (next.has(categoryId)) {
@@ -41,64 +49,69 @@ export const ServiceCatalogCategoriesSidebar: React.FC<
4149
}
4250
return next;
4351
});
44-
};
52+
}, []);
4553

46-
const handleCategorySelect = (categoryId: string) => {
47-
const params = new URLSearchParams(window.location.search);
48-
params.set("category_id", categoryId);
49-
window.history.pushState(
50-
{},
51-
"",
52-
`${window.location.pathname}?${params.toString()}`
53-
);
54+
const handleCategorySelect = useCallback(
55+
(categoryId: string) => {
56+
const params = new URLSearchParams(window.location.search);
57+
params.set("category_id", categoryId);
58+
window.history.pushState(
59+
{},
60+
"",
61+
`${window.location.pathname}?${params.toString()}`
62+
);
5463

55-
window.dispatchEvent(new Event("popstate"));
56-
onSelect(String(categoryId));
57-
};
64+
const ancestors = findAncestorIds(categories, categoryId);
65+
if (ancestors?.length) {
66+
setExpandedCategories((prev) => {
67+
const next = new Set(prev);
68+
for (const id of ancestors) {
69+
next.add(id);
70+
}
71+
return next;
72+
});
73+
}
5874

59-
useEffect(() => {
60-
if (isLoading) return;
61-
if (selectedCategoryId) return;
75+
onSelect(categoryId);
76+
},
77+
[categories, onSelect]
78+
);
6279

63-
const params = new URLSearchParams(window.location.search);
64-
const urlCid = params.get("category_id");
65-
if (urlCid) {
66-
onSelect(String(urlCid));
80+
useEffect(() => {
81+
if (selectedCategoryId) {
82+
const ancestors = findAncestorIds(categories, selectedCategoryId);
83+
if (ancestors?.length) {
84+
setExpandedCategories((prev) => {
85+
const next = new Set(prev);
86+
for (const id of ancestors) {
87+
next.add(id);
88+
}
89+
return next;
90+
});
91+
}
6792
return;
6893
}
6994

70-
if (categories && categories.length > 0) {
71-
const first = categories[0];
72-
if (first) {
73-
onSelect(String(first.id));
74-
}
95+
const firstCategory = categories[0];
96+
if (firstCategory) {
97+
onSelect(firstCategory.id);
7598
}
76-
}, [isLoading, categories, selectedCategoryId]);
77-
78-
if (error) {
79-
throw error;
80-
}
99+
}, [categories, selectedCategoryId, onSelect]);
81100

82101
return (
83102
<Container>
84103
<div>
85-
{isLoading ? (
86-
<LoadingContainer aria-busy="true" aria-live="polite">
87-
<Spinner size="64" />
88-
</LoadingContainer>
89-
) : (
90-
categories.map((category) => (
91-
<CategoryItem
92-
key={category.id}
93-
category={category}
94-
nestingLevel={0}
95-
selectedCategoryId={selectedCategoryId}
96-
onSelect={handleCategorySelect}
97-
expandedCategories={expandedCategories}
98-
onToggleExpand={handleToggleExpand}
99-
/>
100-
))
101-
)}
104+
{categories.map((category) => (
105+
<CategoryItem
106+
key={category.id}
107+
category={category}
108+
nestingLevel={0}
109+
selectedCategoryId={selectedCategoryId}
110+
onSelect={handleCategorySelect}
111+
expandedCategories={expandedCategories}
112+
onToggleExpand={handleToggleExpand}
113+
/>
114+
))}
102115
</div>
103116
</Container>
104117
);

src/modules/service-catalog/components/service-catalog-item/ServiceCatalogItem.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("ServiceCatalogItem", () => {
9696
description: "Test Description",
9797
form_id: 100,
9898
thumbnail_url: "",
99-
categoryId: null,
99+
categories: [],
100100
custom_object_fields: {
101101
"standard::asset_option": "",
102102
"standard::asset_type_option": "",

0 commit comments

Comments
 (0)