Skip to content

Commit

Permalink
Support filtering on quizzes not completed by multiple people (#58)
Browse files Browse the repository at this point in the history
* #57 Move user selector into it's own component so it can be reused
* #57 Fix htmlFor label for participant selector
* #57 Add user selector with wrapper that also handles the loading of available users
* #57 Allow filtering the quiz list with incomplete targetting multiple users
Refactors QuizFilters into QuizControls to better control layout of the top section of the quiz list
* #57 Fix mobile padding for the new exclusion selector control
  • Loading branch information
danielemery authored Jun 28, 2024
1 parent d18f2f8 commit 9a65bc7
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 91 deletions.
25 changes: 8 additions & 17 deletions src/EnterQuizResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useQuizlord } from './QuizlordProvider';
import Button from './components/Button';
import Loader from './components/Loader';
import LoaderOverlay from './components/LoaderOverlay';
import { UserSelector } from './components/UserSelector';
import { formatDate, userIdentifier } from './helpers';
import { COMPLETE_QUIZ, QUIZ, QUIZ_AND_AVAILABLE_USERS, QUIZZES } from './queries/quiz';
import { Quiz } from './types/quiz';
Expand Down Expand Up @@ -85,24 +86,14 @@ export default function EnterQuizResults() {
<p className='text-sm text-gray-500'>
{userIdentifier(authenticatedUser)} <strong>AND</strong>
</p>
<select
multiple
id='participants'
<UserSelector
availableUsers={data.users.edges
.map((user) => user.node)
.filter((userNode) => userNode.email !== authenticatedUser?.email)}
selectedUserEmails={participants}
onSelectionsChanged={setParticipants}
name='participants'
className='mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'
onChange={(e) => {
const items = [...(e.target as HTMLSelectElement).selectedOptions];
setParticipants(items.map((item) => item.value));
}}
>
{data.users.edges
.filter((user) => user.node.email !== authenticatedUser?.email)
.map((user) => (
<option selected={participants.includes(user.node.email)} value={user.node.email}>
{userIdentifier(user.node)}
</option>
))}
</select>
/>
</div>
</>
) : (
Expand Down
35 changes: 35 additions & 0 deletions src/components/UserSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { userIdentifier } from '../helpers';
import { User } from '../types/user';

export interface UserSelectorProps {
availableUsers: User[];
selectedUserEmails: string[];
onSelectionsChanged: (selectedUserEmails: string[]) => void;
name?: string;
}

export function UserSelector({
availableUsers,
selectedUserEmails,
onSelectionsChanged,
name = 'user-selector',
}: UserSelectorProps) {
return (
<select
multiple
id={name}
name={name}
className='mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'
onChange={(e) => {
const items = [...(e.target as HTMLSelectElement).selectedOptions];
onSelectionsChanged(items.map((item) => item.value));
}}
>
{availableUsers.map((user) => (
<option selected={selectedUserEmails.includes(user.email)} value={user.email}>
{userIdentifier(user)}
</option>
))}
</select>
);
}
6 changes: 2 additions & 4 deletions src/hooks/useQuizList.hook.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { useQuery } from '@apollo/client';

import { useQuizlord } from '../QuizlordProvider';
import { QUIZZES } from '../queries/quiz';

export function useQuizList(isFilteringOnIncomplete: boolean, isFilteringOnIllegible: boolean) {
const { user } = useQuizlord();
export function useQuizList(excludedUserEmails: string[], isFilteringOnIllegible: boolean) {
const filters = {
...(isFilteringOnIncomplete ? { excludeCompletedBy: [user?.email] } : {}),
excludeCompletedBy: excludedUserEmails,
...(isFilteringOnIllegible ? { excludeIllegible: 'ANYONE' } : {}),
};
const { loading, data, fetchMore, refetch } = useQuery(QUIZZES, {
Expand Down
36 changes: 16 additions & 20 deletions src/pages/list/QuizList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useNavigate } from 'react-router-dom';

import { faRefresh } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Fragment } from 'preact';
import { useState } from 'preact/hooks';

import { useQuizlord } from '../../QuizlordProvider';
import Button from '../../components/Button';
import Loader from '../../components/Loader';
import { Table } from '../../components/Table';
Expand All @@ -17,7 +16,7 @@ import {
} from '../../helpers';
import { useQuizList } from '../../hooks/useQuizList.hook';
import { User } from '../../types/user';
import { QuizListFilters } from './QuizListFilters';
import { QuizListControls } from './QuizListControls';
import { QuizFilters } from './quizFilters';

interface QuizCompletion {
Expand All @@ -34,33 +33,30 @@ interface Node {
}

export default function QuizList() {
const { user: authenticatedUser } = useQuizlord();
const [quizFilters, setQuizFilters] = useState<QuizFilters>({
isFilteringOnIncomplete: true,
excludedUserEmails: authenticatedUser?.email ? [authenticatedUser?.email] : [],
isFilteringOnIllegible: true,
});
const { data, loading, fetchMore, refetch } = useQuizList(
quizFilters.isFilteringOnIncomplete,
quizFilters.excludedUserEmails,
quizFilters.isFilteringOnIllegible,
);
const navigate = useNavigate();

return (
<>
<div className='flex m-4 lg:m-0 lg:mb-4'>
<QuizListFilters
className='flex-1'
filters={quizFilters}
onFiltersChanged={(changes) =>
setQuizFilters((prevState) => ({
...prevState,
...changes,
}))
}
/>
<div className='flex-0 cursor-pointer' onClick={() => refetch()}>
<FontAwesomeIcon icon={faRefresh} size='xl' className='text-gray-800' />
</div>
</div>
<QuizListControls
className='flex-1'
filters={quizFilters}
onFiltersChanged={(changes) =>
setQuizFilters((prevState) => ({
...prevState,
...changes,
}))
}
onRefreshClicked={() => refetch()}
/>
<div>
<Table className='table-fixed'>
<Table.Head>
Expand Down
121 changes: 121 additions & 0 deletions src/pages/list/QuizListControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { faFilter, faFilterCircleXmark, faRefresh } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState } from 'preact/hooks';

import { useQuizlord } from '../../QuizlordProvider';
import Button from '../../components/Button';
import { userIdentifier } from '../../helpers';
import { UserSelectorWithLoader } from './UserSelectorWithLoader';
import { QuizFilters } from './quizFilters';

export interface QuizListControlsProps {
filters: QuizFilters;
onFiltersChanged: (filterChanges: QuizFilters) => void;
onRefreshClicked: () => void;
className?: string;
}

export function QuizListControls({ filters, onFiltersChanged, onRefreshClicked, className }: QuizListControlsProps) {
const { user: authenticatedUser } = useQuizlord();
const [isSelectingUsers, setIsSelectingUsers] = useState(false);
const [pendingSelections, setPendingSelections] = useState<string[]>(filters.excludedUserEmails);
return (
<>
<div className='flex m-4 lg:m-0 lg:mb-4'>
<div
className={`cursor-pointer${className ? ` ${className}` : ''}`}
onClick={() => setIsSelectingUsers((prevState) => !prevState)}
>
<FontAwesomeIcon
icon={filters.excludedUserEmails ? faFilter : faFilterCircleXmark}
size='xl'
className='text-gray-800'
/>
<span className='ml-4'>{getIncompleteText(filters.excludedUserEmails.length)}</span>
</div>
<div
className={`cursor-pointer${className ? ` ${className}` : ''}`}
onClick={() =>
onFiltersChanged({
...filters,
isFilteringOnIllegible: !filters.isFilteringOnIllegible,
})
}
>
<FontAwesomeIcon
icon={filters.isFilteringOnIllegible ? faFilter : faFilterCircleXmark}
size='xl'
className='text-gray-800'
/>
<span className='ml-4'>{filters.isFilteringOnIllegible ? 'Readable Only' : 'No Readability Filter'}</span>
</div>
<div className='flex-0 cursor-pointer' onClick={() => onRefreshClicked()}>
<FontAwesomeIcon icon={faRefresh} size='xl' className='text-gray-800' />
</div>
</div>
{isSelectingUsers && (
<div className='m-4 lg:m-0 lg:mb-4'>
<label htmlFor='excludedUserEmails' className='block text-sm font-medium text-gray-700'>
Excluded quizzes completed by
</label>
<p className='text-sm text-gray-500'>
{userIdentifier(authenticatedUser)} <strong>OR</strong>
</p>
<UserSelectorWithLoader
selectedUserEmails={filters.excludedUserEmails}
onSelectionsChanged={(newSelections) => {
setPendingSelections(newSelections);
}}
excludeUserEmails={authenticatedUser ? [authenticatedUser.email] : []}
name='excludedUserEmails'
/>

<div className='space-x-2 my-2'>
<Button
onClick={() => {
onFiltersChanged({
...filters,
excludedUserEmails: [...pendingSelections, ...(authenticatedUser ? [authenticatedUser.email] : [])],
});
setIsSelectingUsers(false);
}}
>
Apply
</Button>
<Button
warning
onClick={() => {
onFiltersChanged({
...filters,
excludedUserEmails: [],
});
setIsSelectingUsers(false);
}}
>
Remove Filter
</Button>
<Button
danger
onClick={() => {
setPendingSelections(filters.excludedUserEmails);
setIsSelectingUsers(false);
}}
>
Cancel
</Button>
</div>
</div>
)}
</>
);
}

function getIncompleteText(numExcluded: number) {
if (numExcluded === 0) {
return 'No Incomplete Filter';
}
if (numExcluded === 1) {
return 'Incomplete (You)';
}
return `Incomplete (You + ${numExcluded - 1})`;
}
49 changes: 0 additions & 49 deletions src/pages/list/QuizListFilters.tsx

This file was deleted.

39 changes: 39 additions & 0 deletions src/pages/list/UserSelectorWithLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useQuery } from '@apollo/client';

import Loader from '../../components/Loader';
import { UserSelector } from '../../components/UserSelector';
import { AVAILABLE_USERS } from '../../queries/quiz';
import { User } from '../../types/user';

export interface UserSelectorWithLoaderProps {
selectedUserEmails: string[];
onSelectionsChanged: (selectedUserEmails: string[]) => void;
excludeUserEmails?: string[];
name?: string;
}

export function UserSelectorWithLoader({
selectedUserEmails,
onSelectionsChanged,
name,
excludeUserEmails,
}: UserSelectorWithLoaderProps) {
const { loading, data } = useQuery<{
users: { edges: { node: User }[] };
}>(AVAILABLE_USERS);

if (loading || !data) {
return <Loader message='Loading available users' />;
}

return (
<UserSelector
availableUsers={data.users.edges
.map((edge) => edge.node)
.filter((user) => !excludeUserEmails?.includes(user.email))}
selectedUserEmails={selectedUserEmails}
onSelectionsChanged={onSelectionsChanged}
name={name}
/>
);
}
2 changes: 1 addition & 1 deletion src/pages/list/quizFilters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface QuizFilters {
isFilteringOnIncomplete: boolean;
excludedUserEmails: string[];
isFilteringOnIllegible: boolean;
}
Loading

0 comments on commit 9a65bc7

Please sign in to comment.