Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add filtering by text/role/status, sorting, pagination, collection prefs #367

Merged
merged 18 commits into from
Jan 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
246 changes: 197 additions & 49 deletions apps/site/src/app/admin/participants/components/ParticipantsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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 FormField from "@cloudscape-design/components/form-field";
import Header from "@cloudscape-design/components/header";
import Pagination from "@cloudscape-design/components/pagination";
import Select from "@cloudscape-design/components/select";
import SpaceBetween from "@cloudscape-design/components/space-between";
import Table from "@cloudscape-design/components/table";
import TextFilter from "@cloudscape-design/components/text-filter";
Expand All @@ -12,22 +18,96 @@ import { Participant } from "@/lib/admin/useParticipants";
import ParticipantAction from "./ParticipantAction";
import RoleBadge from "./RoleBadge";

interface EmptyStateProps {
title: string,
subtitle?: string,
action?: ReactElement,
}
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved

interface ParticipantsTableProps {
participants: Participant[];
loading: boolean;
initiateCheckIn: (participant: Participant) => void;
initiatePromotion: (participant: Participant) => void;
}

const ALL_ROLES = { value: '0', label: 'All roles' };
const ALL_STATUSES = { value: '0', label: 'All statuses' };
const SEARCHABLE_COLUMNS = ['_id', 'first_name', 'last_name', 'role', 'status']

alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
function createLabelFunction(columnName: string) {
return ({ sorted, descending }: { sorted: boolean, descending: boolean }) => {
const sortState = sorted ? `sorted ${descending ? 'descending' : 'ascending'}` : 'not sorted';
return `${columnName}, ${sortState}.`;
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
};
}

function EmptyState({ title, subtitle, action }: EmptyStateProps) {
return (
<Box textAlign="center" color="inherit">
<Box variant="strong" textAlign="center" color="inherit">
{title}
</Box>
<Box variant="p" padding={{ bottom: 's' }} color="inherit">
{subtitle}
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
</Box>
{action}
</Box>
);
}

function ParticipantsTable({
participants,
loading,
initiateCheckIn,
initiatePromotion,
}: ParticipantsTableProps) {
// TODO: sorting
// TODO: search functionality
// TODO: role and status filters
const [preferences, setPreferences] = useState({
pageSize: 20,
visibleContent: ['uid', 'firstName', 'lastName', 'role', 'status', 'action'],
});
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
const [filterRole, setFilterRole] = useState(ALL_ROLES);
const [filterStatus, setFilterStatus] = useState(ALL_STATUSES);
const matchesRole = (p: Participant) => filterRole === ALL_ROLES || p.role === filterRole.label;
const matchesStatus = (p: Participant) => filterStatus === ALL_STATUSES || p.status === filterStatus.label;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
const matchesRole = (p: Participant) => filterRole === ALL_ROLES || p.role === filterRole.label;
const matchesStatus = (p: Participant) => filterStatus === ALL_STATUSES || p.status === filterStatus.label;
const matchesRole = (p: Participant) =>
filterRole === ALL_ROLES || p.role === filterRole.label;
const matchesStatus = (p: Participant) =>
filterStatus === ALL_STATUSES || p.status === filterStatus.label;

samderanova marked this conversation as resolved.
Show resolved Hide resolved
const {
items,
actions,
filteredItemsCount,
collectionProps,
filterProps,
paginationProps,
} = useCollection(
participants,
{
filtering: {
empty: <EmptyState title="No participants" />,
noMatch: (
<EmptyState
title="No matches"
action={<Button onClick={() => actions.setFiltering('')}>Clear filter</Button>}
/>
),
filteringFunction: (item, filteringText) => {
if (!matchesRole(item)) { return false; }
if (!matchesStatus(item)) { return false; }
const filteringTextLC = filteringText.toLowerCase();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
} = useCollection(
participants,
{
filtering: {
empty: <EmptyState title="No participants" />,
noMatch: (
<EmptyState
title="No matches"
action={<Button onClick={() => actions.setFiltering('')}>Clear filter</Button>}
/>
),
filteringFunction: (item, filteringText) => {
if (!matchesRole(item)) { return false; }
if (!matchesStatus(item)) { return false; }
const filteringTextLC = filteringText.toLowerCase();
} = useCollection(participants, {
filtering: {
empty: <EmptyState title="No participants" />,
noMatch: (
<EmptyState
title="No matches"
action={
<Button onClick={() => actions.setFiltering("")}>
Clear filter
</Button>
}
/>
),
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().indexOf(filteringTextLC) > -1
samderanova marked this conversation as resolved.
Show resolved Hide resolved
)
samderanova marked this conversation as resolved.
Show resolved Hide resolved
},
samderanova marked this conversation as resolved.
Show resolved Hide resolved
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
return SEARCHABLE_COLUMNS.map(key => item[key]).some(
value => typeof value === 'string' && value.toLowerCase().indexOf(filteringTextLC) > -1
)
},
return SEARCHABLE_COLUMNS.map((key) => item[key]).some(
(value) =>
typeof value === "string" &&
value.toLowerCase().indexOf(filteringTextLC) > -1,
);

pagination: { pageSize: preferences.pageSize },
sorting: {},
selection: {},
}
);
const allRoles = new Set(items.map(p => p.role));
const roleOptions = Array.from(allRoles).map(r => ({ value: r, label: r }));
const allStatuses = new Set(items.map(p => p.status));
const statusOptions = Array.from(allStatuses).map(s => ({ value: s, label: s }));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
pagination: { pageSize: preferences.pageSize },
sorting: {},
selection: {},
}
);
const allRoles = new Set(items.map(p => p.role));
const roleOptions = Array.from(allRoles).map(r => ({ value: r, label: r }));
const allStatuses = new Set(items.map(p => p.status));
const statusOptions = Array.from(allStatuses).map(s => ({ value: s, label: s }));
},
pagination: { pageSize: preferences.pageSize },
sorting: {},
selection: {},
});
const allRoles = new Set(items.map((p) => p.role));
const roleOptions = Array.from(allRoles).map((r) => ({ value: r, label: r }));
const allStatuses = new Set(items.map((p) => p.status));
const statusOptions = Array.from(allStatuses).map((s) => ({
value: s,
label: s,
}));

const ActionCell = useCallback(
(participant: Participant) => (
Expand All @@ -40,6 +120,50 @@ function ParticipantsTable({
[initiateCheckIn, initiatePromotion],
);

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: "action",
header: "Action",
cell: ActionCell,
},
];

const emptyMessage = (
<Box margin={{ vertical: "xs" }} textAlign="center" color="inherit">
<SpaceBetween size="m">
Expand All @@ -50,61 +174,85 @@ function ParticipantsTable({

return (
<Table
// TODO: aria labels
columnDefinitions={[
{
id: "uid",
header: "UID",
cell: (item) => 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: "action",
header: "Action",
cell: ActionCell,
},
]}
{...collectionProps}
header={
<Header counter={`(${participants.length})`}>Participants</Header>
}
items={participants}
columnDefinitions={columnDefinitions}
visibleColumns={preferences.visibleContent}
items={items}
samderanova marked this conversation as resolved.
Show resolved Hide resolved
loading={loading}
loadingText="Loading participants"
resizableColumns
variant="full-page"
stickyColumns={{ first: 1, last: 0 }}
trackBy="_id"
empty={emptyMessage}
filter={
<TextFilter filteringPlaceholder="Find participants" filteringText="" />
}
// TODO: pagination
// TODO: collection preferences
filter={(
<>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
filter={(
filter={

<TextFilter
{...filterProps}
countText={filteredItemsCount === 1 ? '1 participant' : `${filteredItemsCount} participants`}
filteringAriaLabel="Filter participants"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
countText={filteredItemsCount === 1 ? '1 participant' : `${filteredItemsCount} participants`}
countText={
filteredItemsCount === 1
? "1 participant"
: `${filteredItemsCount} participants`
}

/>
<FormField label="Role">
<Select
data-testid="role-filter"
options={[ALL_ROLES].concat(roleOptions)}
selectedAriaLabel="Selected"
selectedOption={filterRole}
onChange={event => setFilterRole(event.detail.selectedOption as { value: string, label: string })}
expandToViewport={true}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
onChange={event => setFilterRole(event.detail.selectedOption as { value: string, label: string })}
onChange={(event) =>
setFilterRole(
event.detail.selectedOption as {
value: string;
label: string;
},
)
}

/>
</FormField>
<FormField label="Status">
<Select
data-testid="status-filter"
options={[ALL_STATUSES].concat(statusOptions)}
selectedAriaLabel="Selected"
samderanova marked this conversation as resolved.
Show resolved Hide resolved
selectedOption={filterStatus}
onChange={event => setFilterStatus(event.detail.selectedOption as { value: string, label: string })}
expandToViewport={true}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
onChange={event => setFilterStatus(event.detail.selectedOption as { value: string, label: string })}
onChange={(event) =>
setFilterStatus(
event.detail.selectedOption as {
value: string;
label: string;
},
)
}

/>
</FormField>
</>
)}
pagination={(
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
<Pagination
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[prettier] reported by reviewdog 🐶

Suggested change
)}
pagination={(
}
pagination={

{...paginationProps}
ariaLabels={{
nextPageLabel: "Next page",
pageLabel: pageNumber => `Go to page ${pageNumber}`,
previousPageLabel: "Previous page",
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
}}
/>
)}
preferences={(
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
<CollectionPreferences
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
pageSizePreference={{
title: "Select page size",
options: [
{ value: 20, label: "20 people" },
{ value: 50, label: "50 people" },
{ value: 100, label: "100 people" },
],
}}
visibleContentPreference={{
title: "Select visible columns",
options: [
{
label: "Participant info",
options: columnDefinitions.map(({ id, header }) => ({ id, label: header }))
}
],
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
}}
cancelLabel="Cancel"
confirmLabel="Confirm"
title="Preferences"
preferences={preferences}
onConfirm={({ detail }) => setPreferences(detail as { pageSize: number, visibleContent: Array<string> })}
/>
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
)}
/>
alanchangxyz marked this conversation as resolved.
Show resolved Hide resolved
);
}
Expand Down
11 changes: 8 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading