From f640aa596fd790977b9cea73904cf8dd50ea9e4c Mon Sep 17 00:00:00 2001 From: Tony CABAYE Date: Thu, 27 Feb 2025 10:53:32 +0100 Subject: [PATCH] feat: add search and pagination on community members page --- assets/components/Layout/PageTitle.tsx | 47 +++++ .../CommunityMembers/CommunityMembers.tsx | 66 +++++-- .../DatasheetList/DatasheetList.locale.tsx | 8 +- .../datasheet/DatasheetList/DatasheetList.tsx | 187 ++++++------------ .../DatasheetList/DatasheetList.types.tsx | 13 -- assets/hooks/useFilters.ts | 53 +++++ assets/hooks/usePagination.ts | 29 +++ assets/hooks/useSearch.ts | 15 ++ assets/hooks/useSort.ts | 42 ++++ assets/router/router.ts | 7 + 10 files changed, 317 insertions(+), 150 deletions(-) create mode 100644 assets/components/Layout/PageTitle.tsx create mode 100644 assets/hooks/useFilters.ts create mode 100644 assets/hooks/usePagination.ts create mode 100644 assets/hooks/useSearch.ts create mode 100644 assets/hooks/useSort.ts diff --git a/assets/components/Layout/PageTitle.tsx b/assets/components/Layout/PageTitle.tsx new file mode 100644 index 00000000..0c968386 --- /dev/null +++ b/assets/components/Layout/PageTitle.tsx @@ -0,0 +1,47 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import ButtonsGroup, { ButtonsGroupProps } from "@codegouvfr/react-dsfr/ButtonsGroup"; +import { ReactNode } from "react"; +import { useStyles } from "tss-react"; + +interface IPageTitleProps { + buttons?: ButtonsGroupProps.Common["buttons"]; + children?: ReactNode; + showButtons?: boolean; + title: ReactNode; +} + +function PageTitle(props: IPageTitleProps) { + const { buttons, children, showButtons, title } = props; + const { css } = useStyles(); + + return ( +
+
+

{title}

+ {children} +
+
+ {showButtons && buttons && ( +
+ +
+ )} +
+
+ ); +} + +export default PageTitle; diff --git a/assets/entrepot/pages/communities/CommunityMembers/CommunityMembers.tsx b/assets/entrepot/pages/communities/CommunityMembers/CommunityMembers.tsx index a24475b9..d7d8ec4a 100644 --- a/assets/entrepot/pages/communities/CommunityMembers/CommunityMembers.tsx +++ b/assets/entrepot/pages/communities/CommunityMembers/CommunityMembers.tsx @@ -23,6 +23,11 @@ import "../../../../sass/pages/community_members.scss"; import { useCommunity } from "../../../../contexts/community"; import Main from "../../../../components/Layout/Main"; import { useDatastore } from "../../../../contexts/datastore"; +import Pagination from "@codegouvfr/react-dsfr/Pagination"; +import { usePagination } from "@/hooks/usePagination"; +import SearchBar from "@codegouvfr/react-dsfr/SearchBar"; +import PageTitle from "@/components/Layout/PageTitle"; +import { useSearch } from "@/hooks/useSearch"; type CommunityMembersProps = { userId?: string; @@ -66,7 +71,7 @@ function CommunityMembers({ userId }: CommunityMembersProps) { const { datastore } = useDatastore(); // Les membres de cette communauté - const { data: communityMembers, isLoading: isLoadingMembers } = useQuery({ + const { data: communityMembers, isLoading } = useQuery({ queryKey: RQKeys.community_members(community._id), queryFn: ({ signal }) => api.community.getMembers(community._id, { signal }), staleTime: 20000, @@ -103,11 +108,14 @@ function CommunityMembers({ userId }: CommunityMembersProps) { return members; }, [communityMembers, communitySupervisor, user?.id]); + const { search,searchedItems } = useSearch(members); + const { limit, page, paginatedItems, totalPages } = usePagination(searchedItems); + useEffect(() => { - if (userId && !isLoadingMembers && !communityMemberIds.includes(userId)) { + if (userId && !isLoading && !communityMemberIds.includes(userId)) { addMemberModal.open(); } - }, [communityMemberIds, isLoadingMembers, userId]); + }, [communityMemberIds, isLoading, userId]); const queryClient = useQueryClient(); @@ -172,8 +180,8 @@ function CommunityMembers({ userId }: CommunityMembersProps) { }} title="Membres" > - {isLoadingMembers && } - {!isLoadingMembers && userId && communityMemberIds.includes(userId) && ( + {isLoading && } + {!isLoading && userId && communityMemberIds.includes(userId) && ( )} - {!isLoadingMembers && ( + {!isLoading && ( <> -

{t("community_members", { communityName: community?.name ?? "" })}

-
- + addMemberModal.open(), + }, + ]} + showButtons + title={t("community_members", { communityName: community?.name ?? "" })} + /> + +
+
+ { + if (!isLoading) { + routes.members_list({ communityId: community._id, userId, search: text }).replace(); + } + }} + allowEmptySearch={true} + big + renderInput={({ className, id, placeholder, type }) => ( + + )} + /> +
+
@@ -209,7 +241,7 @@ function CommunityMembers({ userId }: CommunityMembersProps) { - {members.map((member) => { + {paginatedItems.map((member) => { return (
@@ -250,6 +282,16 @@ function CommunityMembers({ userId }: CommunityMembersProps) { })}
+
+ ({ + ...routes.members_list({ communityId: community._id, userId, page: pageNumber, limit: limit, search }).link, + })} + defaultPage={page} + /> +
{(isRemovePending || isModifyPending) && ( diff --git a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.locale.tsx b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.locale.tsx index 0808af5c..232521e9 100644 --- a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.locale.tsx +++ b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.locale.tsx @@ -3,7 +3,9 @@ import { ReactNode } from "react"; import { formatDateFromISO } from "@/utils"; import { Translations } from "../../../../i18n/types"; -import { FilterEnum, SortByEnum, SortOrderEnum } from "./DatasheetList.types"; +import { FilterEnum } from "@/hooks/useFilters"; +import { SortOrderEnum } from "@/hooks/useSort"; +import { SortByEnum } from "./DatasheetList.types"; const { i18n } = declareComponentKeys< | { K: "title"; P: { datastoreName?: string }; R: string } @@ -42,9 +44,9 @@ export const DatasheetListFrTranslations: Translations<"fr">["DatasheetList"] = switch (filter) { case FilterEnum.ALL: return "Toutes les fiches"; - case FilterEnum.PUBLISHED: + case FilterEnum.ENABLED: return "Fiches publiées"; - case FilterEnum.NOT_PUBLISHED: + case FilterEnum.DISABLED: return "Fiches non publiées"; default: return "Filtre inconnu"; diff --git a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.tsx b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.tsx index 833beefc..eea07c5f 100644 --- a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.tsx +++ b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.tsx @@ -1,15 +1,13 @@ import { fr } from "@codegouvfr/react-dsfr"; import Alert from "@codegouvfr/react-dsfr/Alert"; import Button from "@codegouvfr/react-dsfr/Button"; -import ButtonsGroup from "@codegouvfr/react-dsfr/ButtonsGroup"; import Highlight from "@codegouvfr/react-dsfr/Highlight"; import Pagination from "@codegouvfr/react-dsfr/Pagination"; import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; import SearchBar from "@codegouvfr/react-dsfr/SearchBar"; import SelectNext from "@codegouvfr/react-dsfr/SelectNext"; import { useQuery } from "@tanstack/react-query"; -import { FC, useMemo, useState } from "react"; -import { useStyles } from "tss-react"; +import { FC, useMemo } from "react"; import { Datasheet, EndpointTypeEnum } from "../../../../@types/app"; import Main from "../../../../components/Layout/Main"; @@ -18,43 +16,19 @@ import Skeleton from "../../../../components/Utils/Skeleton"; import { useDatastore } from "../../../../contexts/datastore"; import { useTranslation } from "../../../../i18n/i18n"; import RQKeys from "../../../../modules/entrepot/RQKeys"; -import { routes, useRoute } from "../../../../router/router"; +import { routes } from "../../../../router/router"; import api from "../../../api"; -import { FilterEnum, Sort, SortByEnum, SortOrderEnum } from "./DatasheetList.types"; import DatasheetListItem from "./DatasheetListItem"; +import { usePagination } from "@/hooks/usePagination"; +import PageTitle from "@/components/Layout/PageTitle"; +import { useSearch } from "@/hooks/useSearch"; +import { FilterEnum, useFilters } from "@/hooks/useFilters"; +import { SortOrderEnum, useSort } from "@/hooks/useSort"; +import { SortByEnum } from "./DatasheetList.types"; -const getFilteredList = (list: Datasheet[], filters: FilterEnum[], filterName?: string) => { - if (filterName) { - list = list.filter((d) => d.name.toLowerCase().includes(filterName.toLowerCase())); - } - - if (filters.length === 0) { - return list; - } - - let filtered: Datasheet[] = []; - - if (filters.includes(FilterEnum.PUBLISHED)) { - filtered = [...filtered, ...list.filter((d) => d.nb_publications > 0)]; - } - - if (filters.includes(FilterEnum.NOT_PUBLISHED)) { - filtered = [...filtered, ...list.filter((d) => d.nb_publications === 0)]; - } - return filtered; -}; - -const getSortedList = (list: Datasheet[], sort: Sort) => { - return list.sort((a, b) => { - switch (sort.by) { - case SortByEnum.NB_SERVICES: - return (a.nb_publications - b.nb_publications) * sort.order; - - case SortByEnum.NAME: - default: - return a.name.localeCompare(b.name) * sort.order; - } - }); +const filterTests = { + [FilterEnum.ENABLED]: (d: Datasheet) => d.nb_publications > 0, + [FilterEnum.DISABLED]: (d: Datasheet) => d.nb_publications === 0, }; type DatasheetListProps = { @@ -65,12 +39,6 @@ const DatasheetList: FC = ({ datastoreId }) => { const { datastore, isFetching: isDatastoreFetching } = useDatastore(); const { t: tCommon } = useTranslation("Common"); - const { params } = useRoute(); - const pagination = { - page: params["page"] ? parseInt(params["page"]) : 1, - limit: params["limit"] ? parseInt(params["limit"]) : 20, - }; - const datasheetListQuery = useQuery({ queryKey: RQKeys.datastore_datasheet_list(datastoreId), queryFn: ({ signal }) => api.datasheet.getList(datastoreId, { signal }), @@ -89,58 +57,34 @@ const DatasheetList: FC = ({ datastoreId }) => { ); // filtre et tri - const [searchDatasheetName, setSearchDatasheetName] = useState(); - const [filters, setFilters] = useState([]); - const [sort, setSort] = useState({ by: SortByEnum.NAME, order: SortOrderEnum.ASCENDING }); - - const datasheetList = getSortedList(getFilteredList(data ?? [], filters, searchDatasheetName), sort); - - const { css } = useStyles(); + const { search, searchedItems } = useSearch(data ?? []); + const { filteredItems, filters } = useFilters(searchedItems, ["published"], filterTests); + const { sortBy, sortOrder, sortedItems } = useSort(filteredItems, ["name", "nb_publications"]); + const { limit, page, paginatedItems, totalPages } = usePagination(sortedItems); return (
-
-
-

+ {t("title", { datastoreName: datastore?.name })} {(isDatastoreFetching || isFetching) && } -

- {datastore?.is_sandbox === true && {t("sandbox_datastore_explanation") ?? ""}} -
-
- {/* on attend de savoir si création de nouvelle fiche possible ou pas avant d'afficher le ou les boutons */} - {metadataEndpoint && !datasheetCreationImpossible && ( -
- -
- )} -
-
+ + } + > + {datastore?.is_sandbox === true && {t("sandbox_datastore_explanation") ?? ""}} + {metadataEndpoint && datasheetCreationImpossible && (
@@ -156,8 +100,7 @@ const DatasheetList: FC = ({ datastoreId }) => { label={tCommon("search")} onButtonClick={(text) => { if (!isLoading) { - setSearchDatasheetName(text); - routes.datasheet_list({ datastoreId }).replace(); + routes.datasheet_list({ ...filters, datastoreId, search: text, sortBy, sortOrder }).replace(); } }} allowEmptySearch={true} @@ -178,32 +121,33 @@ const DatasheetList: FC = ({ datastoreId }) => { label: t("filter_option", { filter: FilterEnum.ALL }), nativeInputProps: { value: FilterEnum.ALL.toString(), - checked: filters.length === 0, + checked: filters.published === FilterEnum.ALL, onChange: () => { - setFilters([]); - routes.datasheet_list({ datastoreId }).replace(); + routes.datasheet_list({ datastoreId, search, sortBy, sortOrder }).replace(); }, }, }, { - label: t("filter_option", { filter: FilterEnum.PUBLISHED }), + label: t("filter_option", { filter: FilterEnum.ENABLED }), nativeInputProps: { - value: FilterEnum.PUBLISHED.toString(), - checked: filters.includes(FilterEnum.PUBLISHED), + value: FilterEnum.ENABLED.toString(), + checked: filters.published === FilterEnum.ENABLED, onChange: () => { - setFilters([FilterEnum.PUBLISHED]); - routes.datasheet_list({ datastoreId }).replace(); + routes + .datasheet_list({ ...filters, datastoreId, search, sortBy, sortOrder, published: FilterEnum.ENABLED }) + .replace(); }, }, }, { - label: t("filter_option", { filter: FilterEnum.NOT_PUBLISHED }), + label: t("filter_option", { filter: FilterEnum.DISABLED }), nativeInputProps: { - value: FilterEnum.NOT_PUBLISHED.toString(), - checked: filters.includes(FilterEnum.NOT_PUBLISHED), + value: FilterEnum.DISABLED.toString(), + checked: filters.published === FilterEnum.DISABLED, onChange: () => { - setFilters([FilterEnum.NOT_PUBLISHED]); - routes.datasheet_list({ datastoreId }).replace(); + routes + .datasheet_list({ ...filters, datastoreId, search, sortBy, sortOrder, published: FilterEnum.DISABLED }) + .replace(); }, }, }, @@ -218,31 +162,30 @@ const DatasheetList: FC = ({ datastoreId }) => { options={[ { label: t("sort_option", { sort: SortByEnum.NAME, sortOrder: SortOrderEnum.ASCENDING }), - value: `${SortByEnum.NAME}_${SortOrderEnum.ASCENDING}`, + value: `name|${SortOrderEnum.ASCENDING}`, }, { label: t("sort_option", { sort: SortByEnum.NAME, sortOrder: SortOrderEnum.DESCENDING }), - value: `${SortByEnum.NAME}_${SortOrderEnum.DESCENDING}`, + value: `name|${SortOrderEnum.DESCENDING}`, }, { label: t("sort_option", { sort: SortByEnum.NB_SERVICES, sortOrder: SortOrderEnum.ASCENDING }), - value: `${SortByEnum.NB_SERVICES}_${SortOrderEnum.ASCENDING}`, + value: `nb_publications|${SortOrderEnum.ASCENDING}`, }, { label: t("sort_option", { sort: SortByEnum.NB_SERVICES, sortOrder: SortOrderEnum.DESCENDING }), - value: `${SortByEnum.NB_SERVICES}_${SortOrderEnum.DESCENDING}`, + value: `nb_publications|${SortOrderEnum.DESCENDING}`, }, ]} nativeSelectProps={{ "aria-label": t("sort_label"), - value: `${sort.by}_${sort.order}`, + value: `${sortBy}|${sortOrder}`, onChange: (e) => { - const selectedSort = e.currentTarget.value?.split("_"); - const selectedSortBy = Number(selectedSort?.[0]); + const selectedSort = e.currentTarget.value?.split("|"); + const selectedSortBy = selectedSort?.[0]; const selectedSortOrder = Number(selectedSort?.[1]); - - if (isNaN(selectedSortBy) || selectedSortBy === 0 || isNaN(selectedSortOrder) || selectedSortOrder === 0) return; - setSort((prev) => ({ ...prev, by: selectedSortBy, order: selectedSortOrder })); + if (!selectedSortBy || isNaN(selectedSortOrder) || selectedSortOrder === 0) return; + routes.datasheet_list({ ...filters, datastoreId, search, sortBy: selectedSortBy, sortOrder: selectedSortOrder }).replace(); }, }} placeholder={t("sort_placeholder")} @@ -263,7 +206,7 @@ const DatasheetList: FC = ({ datastoreId }) => { alignItems: "center", }} > -

{t("nb_results", { nb: datasheetList.length })}

+

{t("nb_results", { nb: filteredItems.length })}

= ({ datastoreId }) => {
- {datasheetList - ?.slice((pagination.page - 1) * pagination.limit, pagination.page * pagination.limit) - .map((datasheet: Datasheet) => )} + {paginatedItems.map((datasheet: Datasheet) => ( + + ))}
({ - ...routes.datasheet_list({ datastoreId, page: pageNumber, limit: pagination.limit }).link, + ...routes.datasheet_list({ ...filters, datastoreId, page: pageNumber, limit: limit, search, sortBy, sortOrder }).link, })} - defaultPage={pagination.page} + defaultPage={page} />
diff --git a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.types.tsx b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.types.tsx index f05028d3..79068b6b 100644 --- a/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.types.tsx +++ b/assets/entrepot/pages/datasheet/DatasheetList/DatasheetList.types.tsx @@ -1,17 +1,4 @@ -export enum FilterEnum { - ALL = 0, - PUBLISHED = 1, - NOT_PUBLISHED = 2, -} - export enum SortByEnum { NAME = 1, NB_SERVICES = 2, } - -export enum SortOrderEnum { - ASCENDING = 1, - DESCENDING = -1, -} - -export type Sort = { by: SortByEnum; order: SortOrderEnum }; diff --git a/assets/hooks/useFilters.ts b/assets/hooks/useFilters.ts new file mode 100644 index 00000000..8a01dbc1 --- /dev/null +++ b/assets/hooks/useFilters.ts @@ -0,0 +1,53 @@ +import { useRoute } from "@/router/router"; + +export enum FilterEnum { + ALL = 0, + ENABLED = 1, + DISABLED = 2, +} + +export type IFilters = Record; + +const availableValues = Object.values(FilterEnum); + +const defaultTests = { + [FilterEnum.ENABLED]: (item, key) => Boolean(item[key]), + [FilterEnum.DISABLED]: (item, key) => !item[key], +}; + +function getFilteredList(list: T[], filters: IFilters, tests = defaultTests): T[] { + const entries = Object.entries(filters); + if (entries.length === 0) { + return list; + } + + let filtered: T[] = list; + for (const [key, value] of entries) { + if ((value instanceof Array && value.includes(FilterEnum.ENABLED)) || value === FilterEnum.ENABLED) { + filtered = filtered.filter((item) => tests[FilterEnum.ENABLED](item, key)); + } + if ((value instanceof Array && value.includes(FilterEnum.DISABLED)) || value === FilterEnum.DISABLED) { + filtered = filtered.filter((item) => tests[FilterEnum.DISABLED](item, key)); + } + } + return filtered; +} + +interface IUseFiltersResult { + filteredItems: T[]; + filters: IFilters; +} + +export function useFilters(data: T[], availableFilters: string[], tests = defaultTests): IUseFiltersResult { + const { params } = useRoute(); + const filters = Object.fromEntries( + availableFilters.map((key) => { + const value = params[key] ? parseInt(params[key]) : FilterEnum.ALL; + return [key, availableValues.includes(value) ? value : FilterEnum.ALL]; + }) + ); + return { + filteredItems: getFilteredList(data, filters, tests), + filters, + }; +} diff --git a/assets/hooks/usePagination.ts b/assets/hooks/usePagination.ts new file mode 100644 index 00000000..2aad51c3 --- /dev/null +++ b/assets/hooks/usePagination.ts @@ -0,0 +1,29 @@ +import { useRoute } from "@/router/router"; + +interface IUsePaginationResult { + limit: number; + page: number; + paginatedItems: T[]; + totalPages: number; +} + +export function usePagination(data: T[], availableLimits = [20], defaultPage = 1, defaultLimit = 20): IUsePaginationResult { + const { params } = useRoute(); + let limit = params["limit"] ? parseInt(params["limit"]) : defaultLimit; + if (!availableLimits.includes(limit)) { + limit = defaultLimit; + } + const totalPages = Math.ceil(data.length / limit); + let page = params["page"] ? parseInt(params["page"]) : defaultPage; + if (page > totalPages) { + page = totalPages; + } else if (page < 1) { + page = 1; + } + return { + limit, + page, + paginatedItems: data?.slice((page - 1) * limit, page * limit), + totalPages, + }; +} diff --git a/assets/hooks/useSearch.ts b/assets/hooks/useSearch.ts new file mode 100644 index 00000000..dfb170f5 --- /dev/null +++ b/assets/hooks/useSearch.ts @@ -0,0 +1,15 @@ +import { useRoute } from "@/router/router"; + +interface IUseSearchResult { + search: string; + searchedItems: T[]; +} + +export function useSearch(data: T[], searchProperty = "name"): IUseSearchResult { + const { params } = useRoute(); + const search = params["search"] ?? ""; + return { + search, + searchedItems: search ? data.filter((d) => d[searchProperty].toLowerCase().includes(search.toLowerCase())) : data, + }; +} diff --git a/assets/hooks/useSort.ts b/assets/hooks/useSort.ts new file mode 100644 index 00000000..74809d30 --- /dev/null +++ b/assets/hooks/useSort.ts @@ -0,0 +1,42 @@ +import { useRoute } from "@/router/router"; + +export enum SortOrderEnum { + ASCENDING = 1, + DESCENDING = -1, +} + +const availableSortOrder = Object.values(SortOrderEnum); + +function getSortedList(list: T[], sortBy: string, sortOrder: SortOrderEnum): T[] { + return [...list].sort((a, b) => { + if (typeof a[sortBy] === "string") { + return a[sortBy].localeCompare(b[sortBy]) * sortOrder; + } else if (typeof a[sortBy] === "number") { + return (a[sortBy] - b[sortBy]) * sortOrder; + } + return 0; + }); +} + +interface IUseSortResult { + sortBy: string; + sortOrder: SortOrderEnum; + sortedItems: T[]; +} + +export function useSort(data: T[], availableSortBy = ["name"], defaultSortBy = "name", defaultSortOrder = SortOrderEnum.ASCENDING): IUseSortResult { + const { params } = useRoute(); + let sortBy = params["sortBy"] ?? defaultSortBy; + if (!availableSortBy.includes(sortBy)) { + sortBy = defaultSortBy; + } + let sortOrder = params["sortOrder"] ? parseInt(params["sortOrder"]) : defaultSortOrder; + if (!availableSortOrder.includes(sortOrder)) { + sortOrder = defaultSortOrder; + } + return { + sortBy, + sortOrder, + sortedItems: getSortedList(data, sortBy, sortOrder), + }; +} diff --git a/assets/router/router.ts b/assets/router/router.ts index af946cd5..ecbadf83 100644 --- a/assets/router/router.ts +++ b/assets/router/router.ts @@ -81,6 +81,9 @@ const communityRoutes = { members_list: communityRoute.extend( { userId: param.query.optional.string, + page: param.query.optional.number.default(1), + limit: param.query.optional.number.default(20), + search: param.query.optional.string.default(""), }, () => "/membres" ), @@ -110,6 +113,10 @@ const datastoreRoutes = { { page: param.query.optional.number.default(1), limit: param.query.optional.number.default(20), + search: param.query.optional.string, + sortBy: param.query.optional.string, + sortOrder: param.query.optional.number.default(1), + published: param.query.optional.number.default(0), }, () => "/donnees" ),