From f4407d311a2db3693ac70634103fd75593261eee Mon Sep 17 00:00:00 2001 From: alanchangxyz Date: Fri, 26 Jan 2024 18:23:37 -0800 Subject: [PATCH] Add filtering by text/role/status, sorting, pagination, collection prefs (#367) * Add filtering by text/role/status, sorting, pagination, collection prefs via collection hooks * Fix prettier sgs * Fix more prettier sgs * One more prettier error * Change select->multi, add searching by composite name, three columns filter * Fix prettiers * Fix text filter * More prettiers * More prettiers * Merge main, resolve conflicts * Merge main conflicts again * Add day checkins to default visible columns * More prettiers * More prettiers * Add icons to status filter * Prettier run everything * fix: type error --------- Co-authored-by: Sam Der --- apps/site/package.json | 1 + .../components/ParticipantsFilters.tsx | 109 +++++++ .../components/ParticipantsTable.tsx | 304 ++++++++++++++---- pnpm-lock.yaml | 11 +- 4 files changed, 356 insertions(+), 69 deletions(-) create mode 100644 apps/site/src/app/admin/participants/components/ParticipantsFilters.tsx diff --git a/apps/site/package.json b/apps/site/package.json index 7b8e0750..79bd194a 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@cloudscape-design/collection-hooks": "^1.0.34", "@cloudscape-design/components": "^3.0.475", "@cloudscape-design/global-styles": "^1.0.20", "@fireworks-js/react": "^2.10.7", diff --git a/apps/site/src/app/admin/participants/components/ParticipantsFilters.tsx b/apps/site/src/app/admin/participants/components/ParticipantsFilters.tsx new file mode 100644 index 00000000..0ea4c81d --- /dev/null +++ b/apps/site/src/app/admin/participants/components/ParticipantsFilters.tsx @@ -0,0 +1,109 @@ +import { Dispatch, SetStateAction } from "react"; + +import FormField from "@cloudscape-design/components/form-field"; +import { IconProps } from "@cloudscape-design/components/icon"; +import Multiselect, { + MultiselectProps, +} from "@cloudscape-design/components/multiselect"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import TextFilter, { + TextFilterProps, +} from "@cloudscape-design/components/text-filter"; + +import { + Decision, + PostAcceptedStatus, + ReviewStatus, + Status, +} from "@/lib/admin/useApplicant"; +import { StatusLabels } from "../../applicants/components/ApplicantStatus"; +import type { Options } from "./ParticipantsTable"; + +interface ParticipantsFiltersProps { + filteredItemsCount: number | undefined; + filterProps: TextFilterProps; + roles: Options; + selectedRoles: Options; + setSelectedRoles: Dispatch>; + statuses: Options; + selectedStatuses: Options; + setSelectedStatuses: Dispatch>; +} + +const StatusIcons: Record = { + [ReviewStatus.pending]: "status-pending", + [ReviewStatus.reviewed]: "status-in-progress", + [ReviewStatus.released]: "status-positive", + [Decision.accepted]: "status-positive", + [Decision.rejected]: "status-pending", + [Decision.waitlisted]: "status-negative", + [PostAcceptedStatus.signed]: "status-in-progress", + [PostAcceptedStatus.confirmed]: "status-positive", + [PostAcceptedStatus.attending]: "status-positive", + [PostAcceptedStatus.void]: "status-negative", +}; + +const statusOption = (status: MultiselectProps.Option) => { + if (status.value === undefined) { + throw Error(); + } + return { + label: StatusLabels[status.value as Status], + value: status.value, + iconName: StatusIcons[status.value as Status], + }; +}; + +function ParticipantsFilters({ + filteredItemsCount, + filterProps, + roles, + selectedRoles, + setSelectedRoles, + statuses, + selectedStatuses, + setSelectedStatuses, +}: ParticipantsFiltersProps) { + return ( + +
+ +
+ + setSelectedRoles(event.detail.selectedOptions)} + expandToViewport={true} + /> + + + + setSelectedStatuses(event.detail.selectedOptions) + } + expandToViewport={true} + /> + +
+ ); +} + +export default ParticipantsFilters; diff --git a/apps/site/src/app/admin/participants/components/ParticipantsTable.tsx b/apps/site/src/app/admin/participants/components/ParticipantsTable.tsx index a63a4a16..958d6b80 100644 --- a/apps/site/src/app/admin/participants/components/ParticipantsTable.tsx +++ b/apps/site/src/app/admin/participants/components/ParticipantsTable.tsx @@ -1,22 +1,33 @@ -import { useCallback } from "react"; +import { ReactElement, useCallback, useState } from "react"; +import { useCollection } from "@cloudscape-design/collection-hooks"; import Box from "@cloudscape-design/components/box"; +import Button from "@cloudscape-design/components/button"; +import CollectionPreferences from "@cloudscape-design/components/collection-preferences"; import Header from "@cloudscape-design/components/header"; +import { MultiselectProps } from "@cloudscape-design/components/multiselect"; +import Pagination from "@cloudscape-design/components/pagination"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Table from "@cloudscape-design/components/table"; -import TextFilter from "@cloudscape-design/components/text-filter"; import ApplicantStatus from "@/app/admin/applicants/components/ApplicantStatus"; import { Participant } from "@/lib/admin/useParticipants"; import CheckinDayIcon from "./CheckinDayIcon"; import ParticipantAction from "./ParticipantAction"; +import ParticipantsFilters from "./ParticipantsFilters"; import RoleBadge from "./RoleBadge"; const FRIDAY = new Date("2024-01-26T12:00:00"); const SATURDAY = new Date("2024-01-27T12:00:00"); const SUNDAY = new Date("2024-01-28T12:00:00"); +interface EmptyStateProps { + title: string; + subtitle?: string; + action?: ReactElement; +} + interface ParticipantsTableProps { participants: Participant[]; loading: boolean; @@ -25,6 +36,38 @@ interface ParticipantsTableProps { initiateConfirm: (participant: Participant) => void; } +export type Options = ReadonlyArray; +const SEARCHABLE_COLUMNS: (keyof Participant)[] = [ + "_id", + "first_name", + "last_name", + "role", + "status", +]; + +function createLabelFunction(columnName: string) { + return ({ sorted, descending }: { sorted: boolean; descending: boolean }) => { + const sortState = sorted + ? `sorted ${descending ? "descending" : "ascending"}` + : "not sorted"; + return `${columnName}, ${sortState}.`; + }; +} + +function EmptyState({ title, subtitle, action }: EmptyStateProps) { + return ( + + + {title} + + + {subtitle} + + {action} + + ); +} + function ParticipantsTable({ participants, loading, @@ -32,9 +75,80 @@ function ParticipantsTable({ initiatePromotion, initiateConfirm, }: ParticipantsTableProps) { - // TODO: sorting - // TODO: search functionality - // TODO: role and status filters + const [preferences, setPreferences] = useState({ + pageSize: 20, + visibleContent: [ + "uid", + "firstName", + "lastName", + "role", + "status", + "friday", + "saturday", + "sunday", + "action", + ], + }); + const [filterRole, setFilterRole] = useState([]); + const [filterStatus, setFilterStatus] = useState([]); + const matchesRole = (p: Participant) => + filterRole.length === 0 || filterRole.map((r) => r.value).includes(p.role); + const matchesStatus = (p: Participant) => + filterStatus.length === 0 || + filterStatus.map((s) => s.value).includes(p.status); + + const { + items, + actions, + filteredItemsCount, + collectionProps, + filterProps, + paginationProps, + } = useCollection(participants, { + filtering: { + empty: , + noMatch: ( + actions.setFiltering("")}> + Clear filter + + } + /> + ), + filteringFunction: (item, filteringText) => { + if (!matchesRole(item)) { + return false; + } + if (!matchesStatus(item)) { + return false; + } + const filteringTextLC = filteringText.toLowerCase(); + + return ( + SEARCHABLE_COLUMNS.map((key) => item[key]).some( + (value) => + typeof value === "string" && + value.toLowerCase().includes(filteringTextLC), + ) || + `${item.first_name} ${item.last_name}` + .toLowerCase() + .includes(filteringTextLC) + ); + }, + }, + pagination: { pageSize: preferences.pageSize }, + sorting: {}, + selection: {}, + }); + const allRoles = new Set(participants.map((p) => p.role)); + const roleOptions = Array.from(allRoles).map((r) => ({ value: r, label: r })); + const allStatuses = new Set(participants.map((p) => p.status)); + const statusOptions = Array.from(allStatuses).map((s) => ({ + value: s, + label: s, + })); const ActionCell = useCallback( (participant: Participant) => ( @@ -48,6 +162,68 @@ function ParticipantsTable({ [initiateCheckIn, initiatePromotion, initiateConfirm], ); + const columnDefinitions = [ + { + id: "uid", + header: "UID", + cell: (item: Participant) => item._id, + ariaLabel: createLabelFunction("UID"), + sortingField: "_id", + isRowHeader: true, + }, + { + id: "firstName", + header: "First name", + cell: (item: Participant) => item.first_name, + ariaLabel: createLabelFunction("First name"), + sortingField: "first_name", + }, + { + id: "lastName", + header: "Last name", + cell: (item: Participant) => item.last_name, + ariaLabel: createLabelFunction("Last name"), + sortingField: "last_name", + }, + { + id: "role", + header: "Role", + cell: RoleBadge, + ariaLabel: createLabelFunction("Role"), + sortingField: "role", + }, + { + id: "status", + header: "Status", + cell: ApplicantStatus, + ariaLabel: createLabelFunction("status"), + sortingField: "status", + }, + { + id: "friday", + header: "Fri", + cell: FridayCheckin, + sortingField: "friday", + }, + { + id: "saturday", + header: "Sat", + cell: SaturdayCheckin, + sortingField: "saturday", + }, + { + id: "sunday", + header: "Sun", + cell: SundayCheckin, + sortingField: "sunday", + }, + { + id: "action", + header: "Action", + cell: ActionCell, + }, + ]; + const emptyMessage = ( @@ -58,67 +234,13 @@ function ParticipantsTable({ return ( item._id, - sortingField: "uid", - isRowHeader: true, - }, - { - id: "firstName", - header: "First name", - cell: (item) => item.first_name, - sortingField: "firstName", - }, - { - id: "lastName", - header: "Last name", - cell: (item) => item.last_name, - sortingField: "lastName", - }, - { - id: "role", - header: "Role", - cell: RoleBadge, - sortingField: "role", - }, - { - id: "status", - header: "Status", - cell: ApplicantStatus, - sortingField: "status", - }, - { - id: "friday", - header: "Fri", - cell: FridayCheckin, - sortingField: "friday", - }, - { - id: "saturday", - header: "Sat", - cell: SaturdayCheckin, - sortingField: "saturday", - }, - { - id: "sunday", - header: "Sun", - cell: SundayCheckin, - sortingField: "sunday", - }, - { - id: "action", - header: "Action", - cell: ActionCell, - }, - ]} + {...collectionProps} header={
Participants
} - items={participants} + columnDefinitions={columnDefinitions} + visibleColumns={preferences.visibleContent} + items={items} loading={loading} loadingText="Loading participants" variant="full-page" @@ -126,10 +248,60 @@ function ParticipantsTable({ trackBy="_id" empty={emptyMessage} filter={ - + + } + pagination={ + `Go to page ${pageNumber}`, + previousPageLabel: "Previous page", + }} + /> + } + preferences={ + ({ + id, + label: header, + })), + }, + ], + }} + cancelLabel="Cancel" + confirmLabel="Confirm" + title="Preferences" + preferences={preferences} + onConfirm={({ detail }) => + setPreferences( + detail as { pageSize: number; visibleContent: Array }, + ) + } + /> } - // TODO: pagination - // TODO: collection preferences /> ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index face1491..e02d6270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: apps/site: dependencies: + '@cloudscape-design/collection-hooks': + specifier: ^1.0.34 + version: 1.0.34(react@18.2.0) '@cloudscape-design/components': specifier: ^3.0.475 version: 3.0.475(react-dom@18.2.0)(react@18.2.0) @@ -1518,8 +1521,8 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 - /@cloudscape-design/collection-hooks@1.0.32(react@18.2.0): - resolution: {integrity: sha512-wdOGW+W8+5rYYSGPab/v7BOy0P8mC6aUtwwN3w2AWE3n3LrK/Wqzsx77r/m4zlLdtkhZFnsnSBpT14OIw8ve/Q==} + /@cloudscape-design/collection-hooks@1.0.34(react@18.2.0): + resolution: {integrity: sha512-8ggOJRX4PpiT6YCv7cHSjyx27VUgJX7s25KiLaUbVvm5EeWBpKxrwDwE6w9JjNxrkXeepjs4xyNjaNie2ZiCeQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: @@ -1539,7 +1542,7 @@ packages: react: ^16.8 || ^17 || ^18 react-dom: ^16.8 || ^17 || ^18 dependencies: - '@cloudscape-design/collection-hooks': 1.0.32(react@18.2.0) + '@cloudscape-design/collection-hooks': 1.0.34(react@18.2.0) '@cloudscape-design/component-toolkit': 1.0.0-beta.30 '@cloudscape-design/test-utils-core': 1.0.21 '@cloudscape-design/theming-runtime': 1.0.39 @@ -1566,6 +1569,7 @@ packages: /@cloudscape-design/global-styles@1.0.20: resolution: {integrity: sha512-eEU3o7fZSRtIQVcFj1vRtheDFktRdAMMIidihrpNHqWVZzazFe0Z22vll684Yzy9TQ5R0HXKKupZLgKyC+Ia8w==} dev: false + bundledDependencies: false /@cloudscape-design/test-utils-core@1.0.21: resolution: {integrity: sha512-Kjxtl1ImQLmJ5SJ3PNF0hrtEbKidcZqk3E+iY4dLbJOI3sWh2TwoEpH5i4QEfO2GMtkMNzi41V4mmI4e4sydAw==} @@ -5415,6 +5419,7 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 dev: false + bundledDependencies: false /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}