Skip to content

Commit eeea740

Browse files
Merge pull request #752 from zendesk/kristkvch/PDSC-485-service-items-sidebar
[PDSC-485] service items sidebar
2 parents d46e9d1 + efe5838 commit eeea740

20 files changed

+710
-84
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
import { SIDEBAR_WIDTH } from "../utils/categoryTreeUtils";
6+
7+
const SidebarContainer = styled.div`
8+
width: ${SIDEBAR_WIDTH}px;
9+
flex-shrink: 0;
10+
display: flex;
11+
flex-direction: column;
12+
gap: 8px;
13+
`;
14+
15+
const ContentContainer = styled.div`
16+
flex: 1;
17+
display: flex;
18+
flex-direction: column;
19+
gap: ${(props) => `${props.theme.space.base * 6}px`};
20+
`;
21+
22+
const StyledGrid = styled(Grid)`
23+
padding: 0;
24+
`;
25+
26+
const StyledCol = styled(Grid.Col)`
27+
@media (min-width: 0px) {
28+
margin-bottom: ${(props) => props.theme.space.md};
29+
}
30+
`;
31+
32+
const SidebarSkeleton = () => (
33+
<SidebarContainer>
34+
<Skeleton width="100%" height="40px" />
35+
<Skeleton width="100%" height="40px" />
36+
<Skeleton width="100%" height="40px" />
37+
<Skeleton width="80%" height="40px" />
38+
<Skeleton width="80%" height="40px" />
39+
<Skeleton width="100%" height="40px" />
40+
</SidebarContainer>
41+
);
42+
43+
const ContentSkeleton = () => (
44+
<ContentContainer>
45+
<Skeleton width="200px" height="28px" />
46+
<Skeleton width="320px" height="40px" />
47+
<Skeleton width="120px" height="20px" />
48+
<StyledGrid>
49+
<Grid.Row wrap="wrap">
50+
<StyledCol xs={12} sm={6} md={4} lg={3}>
51+
<Skeleton width="100%" height="140px" />
52+
</StyledCol>
53+
<StyledCol xs={12} sm={6} md={4} lg={3}>
54+
<Skeleton width="100%" height="140px" />
55+
</StyledCol>
56+
<StyledCol xs={12} sm={6} md={4} lg={3}>
57+
<Skeleton width="100%" height="140px" />
58+
</StyledCol>
59+
<StyledCol xs={12} sm={6} md={4} lg={3}>
60+
<Skeleton width="100%" height="140px" />
61+
</StyledCol>
62+
</Grid.Row>
63+
</StyledGrid>
64+
</ContentContainer>
65+
);
66+
67+
export const PageLoadingState: React.FC = () => (
68+
<>
69+
<aside className="service-catalog-sidebar">
70+
<SidebarSkeleton />
71+
</aside>
72+
<main className="service-catalog-list">
73+
<ContentSkeleton />
74+
</main>
75+
</>
76+
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type React from "react";
2+
import { useCallback, useEffect, useMemo, useState } from "react";
3+
import { ServiceCatalogCategoriesSidebar } from "../components/service-catalog-categories-sidebar";
4+
import { ServiceCatalogList } from "./service-catalog-list/ServiceCatalogList";
5+
import type { Category } from "../data-types/Categories";
6+
import { findCategoryById } from "../utils/categoryTreeUtils";
7+
import {
8+
ALL_SERVICES_ID,
9+
UNCATEGORIZED_ID,
10+
} from "./service-catalog-categories-sidebar/constants";
11+
12+
function getCategoryIdFromUrl(): string | null {
13+
const params = new URLSearchParams(window.location.search);
14+
return params.get("category_id");
15+
}
16+
17+
interface ServiceCatalogPageProps {
18+
helpCenterPath: string;
19+
categoryTree: Category[];
20+
}
21+
22+
export const ServiceCatalogPage: React.FC<ServiceCatalogPageProps> = ({
23+
helpCenterPath,
24+
categoryTree,
25+
}) => {
26+
const hasCategories = categoryTree.length > 0;
27+
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
28+
getCategoryIdFromUrl
29+
);
30+
31+
useEffect(() => {
32+
const handlePopState = () => {
33+
setSelectedCategoryId(getCategoryIdFromUrl());
34+
};
35+
window.addEventListener("popstate", handlePopState);
36+
return () => window.removeEventListener("popstate", handlePopState);
37+
}, []);
38+
39+
const selectedCategoryName = useMemo(() => {
40+
if (!selectedCategoryId || !hasCategories) return null;
41+
if (selectedCategoryId === ALL_SERVICES_ID) return ALL_SERVICES_ID;
42+
if (selectedCategoryId === UNCATEGORIZED_ID) return UNCATEGORIZED_ID;
43+
const category = findCategoryById(categoryTree, selectedCategoryId);
44+
return category?.name ?? null;
45+
}, [selectedCategoryId, categoryTree, hasCategories]);
46+
47+
const handleCategorySelect = useCallback((categoryId: string) => {
48+
setSelectedCategoryId(categoryId);
49+
}, []);
50+
51+
return (
52+
<>
53+
{hasCategories && (
54+
<aside className="service-catalog-sidebar">
55+
<ServiceCatalogCategoriesSidebar
56+
categories={categoryTree}
57+
selectedCategoryId={selectedCategoryId}
58+
onSelect={handleCategorySelect}
59+
/>
60+
</aside>
61+
)}
62+
<main className="service-catalog-list">
63+
<ServiceCatalogList
64+
helpCenterPath={helpCenterPath}
65+
selectedCategoryId={hasCategories ? selectedCategoryId : null}
66+
selectedCategoryName={selectedCategoryName}
67+
/>
68+
</main>
69+
</>
70+
);
71+
};
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import type React from "react";
2+
import styled from "styled-components";
3+
import { getColor } from "@zendeskgarden/react-theming";
4+
import ChevronDownIcon from "@zendeskgarden/svg-icons/src/12/chevron-down-fill.svg";
5+
import ChevronUpIcon from "@zendeskgarden/svg-icons/src/12/chevron-up-fill.svg";
6+
import { useTranslation } from "react-i18next";
7+
import type { Category } from "../../data-types/Categories";
8+
import { ALL_SERVICES_ID, UNCATEGORIZED_ID } from "./constants";
9+
10+
const MAX_NESTING_LEVEL = 6;
11+
const INDENT_PER_LEVEL = 20;
12+
const BASE_PADDING_LEFT = 32;
13+
const ARROW_MARGIN_LEFT = 12;
14+
15+
const CategoryItemWrapper = styled.div`
16+
width: 100%;
17+
`;
18+
19+
const CategoryItemContainer = styled.div<{ $nestingLevel: number }>`
20+
display: flex;
21+
align-items: center;
22+
width: 100%;
23+
padding-left: ${(props) => {
24+
const level = Math.min(props.$nestingLevel, MAX_NESTING_LEVEL);
25+
return `${level * INDENT_PER_LEVEL}px`;
26+
}};
27+
`;
28+
29+
const StyledSidebarItem = styled.div<{
30+
$active?: boolean;
31+
$nestingLevel: number;
32+
$hasChildren: boolean;
33+
}>`
34+
display: flex;
35+
align-items: center;
36+
height: 40px;
37+
padding-right: 12px;
38+
padding-left: ${(props) => {
39+
const level = Math.min(props.$nestingLevel, MAX_NESTING_LEVEL);
40+
const baseOffset = BASE_PADDING_LEFT + level * INDENT_PER_LEVEL;
41+
return props.$hasChildren ? `${ARROW_MARGIN_LEFT}px` : `${baseOffset}px`;
42+
}};
43+
cursor: pointer;
44+
background-color: ${(props) =>
45+
props.$active
46+
? `${getColor({ theme: props.theme, hue: "blue", shade: 600 })}33`
47+
: "transparent"};
48+
border-radius: 4px;
49+
50+
&:hover {
51+
background-color: ${(props) =>
52+
props.$active
53+
? `${getColor({ theme: props.theme, hue: "blue", shade: 600 })}47`
54+
: `${getColor({ theme: props.theme, hue: "blue", shade: 600 })}14`};
55+
}
56+
57+
&:focus {
58+
outline: none;
59+
box-shadow: inset 0 0 0 2px
60+
${(props) => getColor({ theme: props.theme, hue: "blue", shade: 600 })};
61+
}
62+
63+
&:focus:not(:focus-visible) {
64+
box-shadow: none;
65+
}
66+
67+
&:focus-visible {
68+
box-shadow: inset 0 0 0 2px
69+
${(props) => getColor({ theme: props.theme, hue: "blue", shade: 600 })};
70+
}
71+
`;
72+
73+
const ExpandButton = styled.button`
74+
display: flex;
75+
align-items: center;
76+
justify-content: center;
77+
background: none;
78+
border: none;
79+
cursor: pointer;
80+
padding: 0;
81+
width: 12px;
82+
height: 12px;
83+
margin-right: 8px;
84+
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
85+
flex-shrink: 0;
86+
87+
&:hover {
88+
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 800 })};
89+
}
90+
91+
svg {
92+
width: 12px;
93+
height: 12px;
94+
}
95+
`;
96+
97+
const ItemContent = styled.div`
98+
display: flex;
99+
align-items: center;
100+
flex: 1;
101+
min-width: 0;
102+
gap: 8px;
103+
`;
104+
105+
const SidebarItemName = styled.span`
106+
flex: 1;
107+
overflow: hidden;
108+
text-overflow: ellipsis;
109+
white-space: nowrap;
110+
`;
111+
112+
const ItemCount = styled.span`
113+
flex-shrink: 0;
114+
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
115+
`;
116+
117+
interface CategoryItemProps {
118+
category: Category;
119+
nestingLevel: number;
120+
selectedCategoryId: string | null;
121+
onSelect: (categoryId: string) => void;
122+
expandedCategories: Set<string>;
123+
onToggleExpand: (categoryId: string) => void;
124+
}
125+
126+
export const CategoryItem: React.FC<CategoryItemProps> = ({
127+
category,
128+
nestingLevel,
129+
expandedCategories,
130+
onToggleExpand,
131+
selectedCategoryId,
132+
onSelect,
133+
}) => {
134+
const hasChildren = category.children.length > 0;
135+
const isExpanded = expandedCategories.has(category.id);
136+
const isAllServices = category.id === ALL_SERVICES_ID;
137+
const isUncategorized = category.id === UNCATEGORIZED_ID;
138+
const isSelected = selectedCategoryId === category.id;
139+
140+
const { t } = useTranslation();
141+
142+
const displayName = isAllServices
143+
? t("service-catalog-sidebar.all-services", "All services")
144+
: isUncategorized
145+
? t("service-catalog-sidebar.uncategorized", "Uncategorized")
146+
: category.name;
147+
148+
const handleExpandClick = (e: React.MouseEvent) => {
149+
e.stopPropagation();
150+
onToggleExpand(category.id);
151+
};
152+
153+
return (
154+
<>
155+
<CategoryItemWrapper>
156+
<StyledSidebarItem
157+
$active={isSelected}
158+
$nestingLevel={nestingLevel}
159+
$hasChildren={hasChildren}
160+
onClick={() => onSelect(category.id)}
161+
tabIndex={0}
162+
role="button"
163+
onKeyDown={(e) => {
164+
if (e.key === "Enter" || e.key === " ") {
165+
e.preventDefault();
166+
onSelect(category.id);
167+
}
168+
}}
169+
data-test-id={
170+
isAllServices
171+
? "sidebar-all-services"
172+
: `sidebar-category-${category.id}`
173+
}
174+
>
175+
<CategoryItemContainer $nestingLevel={hasChildren ? nestingLevel : 0}>
176+
{hasChildren && (
177+
<ExpandButton
178+
onClick={handleExpandClick}
179+
aria-label={
180+
isExpanded
181+
? t(
182+
"service-catalog-sidebar.collapse-category",
183+
"Collapse category"
184+
)
185+
: t(
186+
"service-catalog-sidebar.expand-category",
187+
"Expand category"
188+
)
189+
}
190+
type="button"
191+
>
192+
{isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
193+
</ExpandButton>
194+
)}
195+
<ItemContent>
196+
<SidebarItemName>{displayName}</SidebarItemName>
197+
<ItemCount>{category.items_count}</ItemCount>
198+
</ItemContent>
199+
</CategoryItemContainer>
200+
</StyledSidebarItem>
201+
</CategoryItemWrapper>
202+
{hasChildren && isExpanded && (
203+
<>
204+
{category.children.map((child) => (
205+
<CategoryItem
206+
key={child.id}
207+
category={child}
208+
nestingLevel={nestingLevel + 1}
209+
selectedCategoryId={selectedCategoryId}
210+
onSelect={onSelect}
211+
expandedCategories={expandedCategories}
212+
onToggleExpand={onToggleExpand}
213+
/>
214+
))}
215+
</>
216+
)}
217+
</>
218+
);
219+
};

0 commit comments

Comments
 (0)