Skip to content

Commit

Permalink
Extract PaginatedMultiSelect to its own module
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 17, 2024
1 parent e9c2416 commit a8f33e2
Show file tree
Hide file tree
Showing 4 changed files with 374 additions and 320 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import {
Button,
FilterClearIcon,
LinkButton,
MultiSelect,
RefreshIcon,
} from '@hypothesis/frontend-shared';
import type { ComponentChildren } from 'preact';
import type { MutableRef } from 'preact/hooks';
import { useMemo, useRef } from 'preact/hooks';
import { useMemo } from 'preact/hooks';
import { useParams } from 'wouter-preact';

import type {
Expand All @@ -19,9 +16,9 @@ import type {
StudentsResponse,
} from '../../api-types';
import { useConfig } from '../../config';
import type { PaginatedFetchResult } from '../../utils/api';
import { usePaginatedAPIFetch } from '../../utils/api';
import { formatDateTime } from '../../utils/date';
import PaginatedMultiSelect from './PaginatedMultiSelect';

/**
* Allow the user to select items from a paginated list of all items matching
Expand Down Expand Up @@ -60,18 +57,6 @@ export type DashboardActivityFiltersProps = {
onClearSelection?: () => void;
};

/**
* Checks if provided element's scroll is at the bottom.
* @param offset - Return true if the difference between the element's current
* and maximum scroll position is below this value.
* Defaults to 20.
*/
function elementScrollIsAtBottom(element: HTMLElement, offset = 20): boolean {
const distanceToTop = element.scrollTop + element.clientHeight;
const triggerPoint = element.scrollHeight - offset;
return distanceToTop >= triggerPoint;
}

type PropsWithElementRef<T> = T & {
/** Ref to be used on a `Select.Option` element */
elementRef?: MutableRef<HTMLElement | null>;
Expand Down Expand Up @@ -121,139 +106,6 @@ function StudentOption({
);
}

type FiltersEntity = 'courses' | 'assignments' | 'students';

/**
* Placeholder to indicate a loading is in progress in one of the dropdowns
*/
function LoadingOption({ entity }: { entity: FiltersEntity }) {
return (
<div
className="py-2 px-4 mb-1 text-grey-4 italic"
data-testid={`loading-more-${entity}`}
>
Loading more {entity}...
</div>
);
}

type LoadingErrorProps = {
entity: FiltersEntity;
retry: () => void;
};

/**
* Indicates an error occurred while loading filters, and presents a button to
* retry.
*/
function LoadingError({ entity, retry }: LoadingErrorProps) {
return (
<div
className="flex gap-2 items-center py-2 pl-4 pr-2.5 mb-1"
data-testid={`${entity}-error`}
// Make this element "focusable" so that clicking on it does not cause
// the listbox containing it to be closed
tabIndex={-1}
>
<span className="italic text-red-error">Error loading {entity}</span>
<Button icon={RefreshIcon} onClick={retry} size="sm">
Retry
</Button>
</div>
);
}

type PaginatedMultiSelectProps<TResult, TSelect> = {
result: PaginatedFetchResult<NonNullable<TResult>[]>;
activeItem?: TResult;
renderOption: (
item: NonNullable<TResult>,
ref?: MutableRef<HTMLElement | null>,
) => ComponentChildren;
entity: FiltersEntity;
buttonContent?: ComponentChildren;
value: TSelect[];
onChange: (newValue: TSelect[]) => void;
};

/**
* A MultiSelect whose data is fetched from a paginated API.
* It includes loading and error indicators, and transparently loads more data
* while scrolling.
*/
function PaginatedMultiSelect<TResult, TSelect>({
result,
activeItem,
entity,
renderOption,
buttonContent,
value,
onChange,
}: PaginatedMultiSelectProps<TResult, TSelect>) {
const lastOptionRef = useRef<HTMLElement | null>(null);

return (
<MultiSelect
disabled={result.isLoadingFirstPage}
value={value}
onChange={onChange}
aria-label={`Select ${entity}`}
containerClasses="!w-auto min-w-[180px]"
buttonContent={buttonContent}
data-testid={`${entity}-select`}
onListboxScroll={e => {
if (elementScrollIsAtBottom(e.target as HTMLUListElement)) {
result.loadNextPage();
}
}}
>
<MultiSelect.Option
value={undefined}
elementRef={
!activeItem && (!result.data || result.data.length === 0)
? lastOptionRef
: undefined
}
>
All {entity}
</MultiSelect.Option>
{activeItem ? (
renderOption(activeItem, lastOptionRef)
) : (
<>
{result.data?.map((item, index, list) =>
renderOption(
item,
list.length - 1 === index ? lastOptionRef : undefined,
),
)}
{result.isLoading && <LoadingOption entity={entity} />}
{result.error && (
<LoadingError
entity={entity}
retry={() => {
// Focus last option before retrying, to avoid the listbox to
// be closed:
// - Starting the fetch retry will cause the result to no
// longer be in the error state, hence the Retry button will
// be umounted.
// - If the retry button had focus when unmounted, the focus
// would revert to the document body.
// - Since the body is outside the select dropdown, this would
// cause the select dropdown to auto-close.
// - To avoid this we focus a different element just before
// initiating the retry.
lastOptionRef.current?.focus();
result.retry();
}}
/>
)}
</>
)}
</MultiSelect>
);
}

/**
* Renders drop-downs to select courses, assignments and/or students, used to
* filter dashboard activity metrics.
Expand Down Expand Up @@ -343,6 +195,7 @@ export default function DashboardActivityFilters({
<div className="flex gap-2 flex-wrap">
<PaginatedMultiSelect
entity="courses"
data-testid="courses-select"
result={coursesResult}
value={selectedCourseIds}
onChange={newCourseIds =>
Expand Down Expand Up @@ -377,6 +230,7 @@ export default function DashboardActivityFilters({
/>
<PaginatedMultiSelect
entity="assignments"
data-testid="assignments-select"
result={assignmentsResults}
value={selectedAssignmentIds}
onChange={newAssignmentIds =>
Expand Down Expand Up @@ -410,6 +264,7 @@ export default function DashboardActivityFilters({
/>
<PaginatedMultiSelect
entity="students"
data-testid="students-select"
result={studentsResult}
value={students.selectedIds}
onChange={newStudentIds => students.onChange(newStudentIds)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Button, MultiSelect, RefreshIcon } from '@hypothesis/frontend-shared';
import type { ComponentChildren } from 'preact';
import type { MutableRef } from 'preact/hooks';
import { useRef } from 'preact/hooks';

import type { PaginatedFetchResult } from '../../utils/api';

type FiltersEntity = 'courses' | 'assignments' | 'students';

/**
* Placeholder to indicate a loading is in progress in one of the dropdowns
*/
function LoadingOption({ entity }: { entity: FiltersEntity }) {
return (
<div className="py-2 px-4 mb-1 text-grey-4 italic">
Loading more {entity}...
</div>
);
}

type LoadingErrorProps = {
entity: FiltersEntity;
retry: () => void;
};

/**
* Indicates an error occurred while loading filters, and presents a button to
* retry.
*/
function LoadingError({ entity, retry }: LoadingErrorProps) {
return (
<div
className="flex gap-2 items-center py-2 pl-4 pr-2.5 mb-1"
// Make this element "focusable" so that clicking on it does not cause
// the listbox containing it to be closed
tabIndex={-1}
>
<span className="italic text-red-error">Error loading {entity}</span>
<Button
icon={RefreshIcon}
onClick={retry}
size="sm"
data-testid="retry-button"
>
Retry
</Button>
</div>
);
}

/**
* Checks if provided element's scroll is at the bottom.
*
* @param offset - Return true if the difference between the element's current
* and maximum scroll position is below this value.
* Defaults to 20.
*/
function elementScrollIsAtBottom(element: HTMLElement, offset = 20): boolean {
const distanceToTop = element.scrollTop + element.clientHeight;
const triggerPoint = element.scrollHeight - offset;
return distanceToTop >= triggerPoint;
}

export type PaginatedMultiSelectProps<TResult, TSelect> = {
result: PaginatedFetchResult<NonNullable<TResult>[]>;
activeItem?: TResult;
renderOption: (
item: NonNullable<TResult>,
ref?: MutableRef<HTMLElement | null>,
) => ComponentChildren;
entity: FiltersEntity;
buttonContent?: ComponentChildren;
value: TSelect[];
onChange: (newValue: TSelect[]) => void;
};

/**
* A MultiSelect whose data is fetched from a paginated API.
* It includes loading and error indicators, and transparently loads more data
* while scrolling.
*/
export default function PaginatedMultiSelect<TResult, TSelect>({
result,
activeItem,
entity,
renderOption,
buttonContent,
value,
onChange,
}: PaginatedMultiSelectProps<TResult, TSelect>) {
const lastOptionRef = useRef<HTMLElement | null>(null);

return (
<MultiSelect
disabled={result.isLoadingFirstPage}
value={value}
onChange={onChange}
aria-label={`Select ${entity}`}
containerClasses="!w-auto min-w-[180px]"
buttonContent={buttonContent}
data-testid={`${entity}-select`}
onListboxScroll={e => {
if (elementScrollIsAtBottom(e.target as HTMLUListElement)) {
result.loadNextPage();
}
}}
>
<MultiSelect.Option
value={undefined}
elementRef={
!activeItem && (!result.data || result.data.length === 0)
? lastOptionRef
: undefined
}
>
All {entity}
</MultiSelect.Option>
{activeItem ? (
renderOption(activeItem, lastOptionRef)
) : (
<>
{result.data?.map((item, index, list) =>
renderOption(
item,
list.length - 1 === index ? lastOptionRef : undefined,
),
)}
{result.isLoading && <LoadingOption entity={entity} />}
{result.error && (
<LoadingError
entity={entity}
retry={() => {
// Focus last option before retrying, to avoid the listbox to
// be closed:
// - Starting the fetch retry will cause the result to no
// longer be in the error state, hence the Retry button will
// be umounted.
// - If the retry button had focus when unmounted, the focus
// would revert to the document body.
// - Since the body is outside the select dropdown, this would
// cause the select dropdown to auto-close.
// - To avoid this we focus a different element just before
// initiating the retry.
lastOptionRef.current?.focus();
result.retry();
}}
/>
)}
</>
)}
</MultiSelect>
);
}
Loading

0 comments on commit a8f33e2

Please sign in to comment.