Skip to content

Commit 19d74f5

Browse files
authored
Merge pull request #114 from CivicDataLab/113-add-categories-pages-to-the-consumer-side
Add categories pages to the consumer side
2 parents de002b6 + 2fac891 commit 19d74f5

File tree

6 files changed

+537
-26
lines changed

6 files changed

+537
-26
lines changed
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
'use client';
2+
3+
import GraphqlPagination from '@/app/[locale]/dashboard/components/GraphqlPagination/graphqlPagination';
4+
import { fetchDatasets } from '@/fetch';
5+
import { graphql } from '@/gql';
6+
import { useQuery } from '@tanstack/react-query';
7+
import Image from 'next/image';
8+
import { useRouter } from 'next/navigation';
9+
import { Pill, SearchInput, Select, Text } from 'opub-ui';
10+
import { useEffect, useReducer, useState } from 'react';
11+
12+
import BreadCrumbs from '@/components/BreadCrumbs';
13+
import { ErrorPage } from '@/components/error';
14+
import { Loading } from '@/components/loading';
15+
import { GraphQL } from '@/lib/api';
16+
import Card from '../../datasets/components/Card';
17+
import Filter from '../../datasets/components/FIlter/Filter';
18+
19+
const categoryQueryDoc: any = graphql(`
20+
query CategoryDetails($filters: CategoryFilter) {
21+
categories(filters: $filters) {
22+
id
23+
name
24+
description
25+
datasetCount
26+
}
27+
}
28+
`);
29+
30+
interface Bucket {
31+
key: string;
32+
doc_count: number;
33+
}
34+
35+
interface Aggregation {
36+
buckets: Bucket[];
37+
}
38+
39+
interface Aggregations {
40+
[key: string]: Aggregation;
41+
}
42+
43+
interface FilterOptions {
44+
[key: string]: string[];
45+
}
46+
47+
interface QueryParams {
48+
pageSize: number;
49+
currentPage: number;
50+
filters: FilterOptions;
51+
query?: string;
52+
}
53+
54+
type Action =
55+
| { type: 'SET_PAGE_SIZE'; payload: number }
56+
| { type: 'SET_CURRENT_PAGE'; payload: number }
57+
| { type: 'SET_FILTERS'; payload: { category: string; values: string[] } }
58+
| { type: 'REMOVE_FILTER'; payload: { category: string; value: string } }
59+
| { type: 'SET_QUERY'; payload: string }
60+
| { type: 'INITIALIZE'; payload: QueryParams };
61+
62+
const initialState: QueryParams = {
63+
pageSize: 5,
64+
currentPage: 1,
65+
filters: {},
66+
query: '',
67+
};
68+
69+
const queryReducer = (state: QueryParams, action: Action): QueryParams => {
70+
switch (action.type) {
71+
case 'SET_PAGE_SIZE': {
72+
return { ...state, pageSize: action.payload, currentPage: 1 };
73+
}
74+
case 'SET_CURRENT_PAGE': {
75+
return { ...state, currentPage: action.payload };
76+
}
77+
case 'SET_FILTERS': {
78+
return {
79+
...state,
80+
filters: {
81+
...state.filters,
82+
[action.payload.category]: action.payload.values,
83+
},
84+
currentPage: 1,
85+
};
86+
}
87+
case 'REMOVE_FILTER': {
88+
const newFilters = { ...state.filters };
89+
newFilters[action.payload.category] = newFilters[
90+
action.payload.category
91+
].filter((v) => v !== action.payload.value);
92+
return { ...state, filters: newFilters, currentPage: 1 };
93+
}
94+
case 'SET_QUERY': {
95+
return { ...state, query: action.payload };
96+
}
97+
case 'INITIALIZE': {
98+
return { ...state, ...action.payload };
99+
}
100+
default:
101+
return state;
102+
}
103+
};
104+
105+
const useUrlParams = (
106+
queryParams: QueryParams,
107+
setQueryParams: React.Dispatch<Action>,
108+
setVariables: (vars: string) => void
109+
) => {
110+
const router = useRouter();
111+
112+
useEffect(() => {
113+
const urlParams = new URLSearchParams(window.location.search);
114+
const sizeParam = urlParams.get('size');
115+
const pageParam = urlParams.get('page');
116+
const filters: FilterOptions = {};
117+
118+
urlParams.forEach((value, key) => {
119+
if (!['size', 'page', 'query'].includes(key)) {
120+
filters[key] = value.split(',');
121+
}
122+
});
123+
124+
const initialParams: QueryParams = {
125+
pageSize: sizeParam ? Number(sizeParam) : 5,
126+
currentPage: pageParam ? Number(pageParam) : 1,
127+
filters,
128+
query: urlParams.get('query') || '',
129+
};
130+
131+
setQueryParams({ type: 'INITIALIZE', payload: initialParams });
132+
}, [setQueryParams]);
133+
134+
useEffect(() => {
135+
const filtersString = Object.entries(queryParams.filters)
136+
.filter(([_, values]) => values.length > 0)
137+
.map(([key, values]) => `${key}=${values.join(',')}`)
138+
.join('&');
139+
140+
const searchParam = queryParams.query
141+
? `&query=${encodeURIComponent(queryParams.query)}`
142+
: '';
143+
const variablesString = `?${filtersString}&size=${queryParams.pageSize}&page=${queryParams.currentPage}${searchParam}`;
144+
setVariables(variablesString);
145+
146+
const currentUrl = new URL(window.location.href);
147+
currentUrl.searchParams.set('size', queryParams.pageSize.toString());
148+
currentUrl.searchParams.set('page', queryParams.currentPage.toString());
149+
150+
Object.entries(queryParams.filters).forEach(([key, values]) => {
151+
if (values.length > 0) {
152+
currentUrl.searchParams.set(key, values.join(','));
153+
} else {
154+
currentUrl.searchParams.delete(key);
155+
}
156+
});
157+
158+
if (queryParams.query) {
159+
currentUrl.searchParams.set('query', queryParams.query);
160+
} else {
161+
currentUrl.searchParams.delete('query');
162+
}
163+
164+
router.push(currentUrl.toString());
165+
}, [queryParams, setVariables, router]);
166+
};
167+
168+
const CategoryDetailsPage = ({ params }: { params: { categorySlug: any } }) => {
169+
const getCategoryDetails: {
170+
data: any;
171+
isLoading: boolean;
172+
isError: boolean;
173+
} = useQuery([`get_category_details_${params.categorySlug}`], () =>
174+
GraphQL(categoryQueryDoc, { filters: { slug: params.categorySlug } })
175+
);
176+
177+
const [facets, setFacets] = useState<{
178+
results: any[];
179+
total: number;
180+
aggregations: Aggregations;
181+
} | null>(null);
182+
const [variables, setVariables] = useState('');
183+
const [open, setOpen] = useState(false);
184+
const count = facets?.total ?? 0;
185+
const datasetDetails = facets?.results ?? [];
186+
const [queryParams, setQueryParams] = useReducer(queryReducer, initialState);
187+
188+
useEffect(() => {
189+
if (variables) {
190+
fetchDatasets(variables)
191+
.then((res) => {
192+
setFacets(res);
193+
})
194+
.catch((err) => {
195+
console.error(err);
196+
});
197+
}
198+
}, [variables]);
199+
200+
useUrlParams(queryParams, setQueryParams, setVariables);
201+
202+
const handlePageChange = (newPage: number) => {
203+
setQueryParams({ type: 'SET_CURRENT_PAGE', payload: newPage });
204+
};
205+
206+
const handlePageSizeChange = (newSize: number) => {
207+
setQueryParams({ type: 'SET_PAGE_SIZE', payload: newSize });
208+
};
209+
210+
const handleFilterChange = (category: string, values: string[]) => {
211+
setQueryParams({ type: 'SET_FILTERS', payload: { category, values } });
212+
};
213+
214+
const handleRemoveFilter = (category: string, value: string) => {
215+
setQueryParams({ type: 'REMOVE_FILTER', payload: { category, value } });
216+
};
217+
218+
const handleSearch = (searchTerm: string) => {
219+
setQueryParams({ type: 'SET_QUERY', payload: searchTerm });
220+
};
221+
222+
const aggregations: Aggregations = facets?.aggregations || {};
223+
224+
const filterOptions = Object.entries(aggregations).reduce(
225+
(acc: Record<string, { label: string; value: string }[]>, [key, value]) => {
226+
acc[key.replace('.raw', '')] = value.buckets.map((bucket: Bucket) => ({
227+
label: bucket.key,
228+
value: bucket.key,
229+
}));
230+
return acc;
231+
},
232+
{}
233+
);
234+
235+
return (
236+
<div className="bg-basePureWhite">
237+
<BreadCrumbs
238+
data={[
239+
{ href: '/', label: 'Home' },
240+
{ href: '/categories', label: 'Categories' },
241+
{
242+
href: '#',
243+
label:
244+
getCategoryDetails.data?.categories[0].name ||
245+
params.categorySlug,
246+
},
247+
]}
248+
/>
249+
250+
{getCategoryDetails.isError ? (
251+
<ErrorPage />
252+
) : getCategoryDetails.isLoading ? (
253+
<Loading />
254+
) : (
255+
<div className="min-h-screen">
256+
<div className="flex flex-col items-center gap-8 py-3 lg:flex-row lg:px-28 lg:py-10">
257+
<div className="flex flex-col items-center justify-center rounded-2 bg-baseGraySlateSolid2 p-2">
258+
<Image
259+
src={'/obi.jpg'}
260+
width={164}
261+
height={164}
262+
alt={`${params.categorySlug} Logo`}
263+
/>
264+
</div>
265+
<div className="flex flex-col gap-4 p-2">
266+
<Text
267+
variant="heading3xl"
268+
as="h1"
269+
// className="text-baseIndigoAlpha4"
270+
fontWeight="bold"
271+
>
272+
{getCategoryDetails.data?.categories[0].name ||
273+
params.categorySlug}
274+
</Text>
275+
<Text variant="bodyLg">
276+
{getCategoryDetails.data?.categories[0].datasetCount} Datasets
277+
</Text>
278+
<Text variant="bodyMd">
279+
{getCategoryDetails.data?.categories[0].description ||
280+
'No description available.'}
281+
</Text>
282+
</div>
283+
</div>
284+
285+
<div>
286+
<div className="mx-10 my-4 flex flex-wrap items-center justify-between gap-6 rounded-2 bg-baseBlueSolid4 px-4 py-2">
287+
<div>
288+
<Text>Showing 10 of 30 Datasets</Text>
289+
</div>
290+
<div className=" w-full max-w-[550px] md:block">
291+
<SearchInput
292+
label="Search"
293+
name="Search"
294+
// className={cn(Styles.Search)}
295+
placeholder="Search datasets"
296+
onSubmit={(value: any) => console.log(value)}
297+
onClear={(value: any) => console.log(value)}
298+
/>
299+
</div>
300+
<div className="flex flex-wrap items-center justify-between gap-4">
301+
<div className="flex items-center gap-2">
302+
<Text
303+
variant="bodyLg"
304+
className="font-bold text-baseBlueSolid8"
305+
>
306+
Sort by:
307+
</Text>
308+
<Select
309+
label=""
310+
labelInline
311+
name="select"
312+
options={[
313+
{
314+
label: 'Newest',
315+
value: 'newestUpdate',
316+
},
317+
{
318+
label: 'Oldest',
319+
value: 'oldestUpdate',
320+
},
321+
]}
322+
/>
323+
</div>
324+
<div className="flex items-center gap-2">
325+
<Text
326+
variant="bodyLg"
327+
className="font-bold text-baseBlueSolid8"
328+
>
329+
Rows:
330+
</Text>
331+
<Select
332+
label=""
333+
labelInline
334+
name="select"
335+
options={[
336+
{
337+
label: '10',
338+
value: '10',
339+
},
340+
{
341+
label: '20',
342+
value: '20',
343+
},
344+
]}
345+
/>
346+
</div>
347+
</div>
348+
</div>
349+
</div>
350+
351+
<div className="row mx-10 mb-16 flex gap-5">
352+
<div className="hidden min-w-64 max-w-64 lg:block">
353+
<Filter
354+
options={filterOptions}
355+
setSelectedOptions={handleFilterChange}
356+
selectedOptions={queryParams.filters}
357+
/>
358+
</div>
359+
<div className="flex h-full w-full flex-col px-2">
360+
<div className="flex gap-2 border-b-2 border-solid border-baseGraySlateSolid4 pb-4">
361+
{Object.entries(queryParams.filters).map(([category, values]) =>
362+
values.map((value) => (
363+
<Pill
364+
key={`${category}-${value}`}
365+
onRemove={() => handleRemoveFilter(category, value)}
366+
>
367+
{value}
368+
</Pill>
369+
))
370+
)}
371+
</div>
372+
373+
<div className="flex flex-col gap-6">
374+
{facets && datasetDetails?.length > 0 && (
375+
<GraphqlPagination
376+
totalRows={count}
377+
pageSize={queryParams.pageSize}
378+
currentPage={queryParams.currentPage}
379+
onPageChange={handlePageChange}
380+
onPageSizeChange={handlePageSizeChange}
381+
>
382+
{datasetDetails.map((item: any, index: any) => (
383+
<Card key={index} data={item} />
384+
))}
385+
</GraphqlPagination>
386+
)}
387+
</div>
388+
</div>
389+
</div>
390+
</div>
391+
)}
392+
</div>
393+
);
394+
};
395+
396+
export default CategoryDetailsPage;

0 commit comments

Comments
 (0)