Skip to content

Commit 1fc32cd

Browse files
authored
Merge pull request #192 from danielemery/184-persist-quiz-list-filters-in-local-storage
Persist quiz list filters in local storage (indexedDB)
2 parents 1d31f61 + 968c84e commit 1fc32cd

File tree

4 files changed

+197
-61
lines changed

4 files changed

+197
-61
lines changed

src/hooks/useQuizFilters.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { liveQuery } from 'dexie';
2+
import { useEffect, useState } from 'preact/hooks';
3+
4+
import { db } from '../services/db';
5+
6+
const EXCLUDED_USER_EMAILS_KEY = 'QUIZ_FILTER_EXCLUDED_USER_EMAILS';
7+
const IS_FILTERING_ON_ILLEGIBLE_KEY = 'QUIZ_FILTER_IS_FILTERING_ON_ILLEGIBLE';
8+
const CURRENT_USER_KEY = 'CURRENT_USER_EMAIL';
9+
10+
export interface QuizFilters {
11+
excludedUserEmails: string[];
12+
isFilteringOnIllegible: boolean;
13+
}
14+
15+
const DEFAULT_FILTERS: QuizFilters = {
16+
excludedUserEmails: [],
17+
isFilteringOnIllegible: true,
18+
};
19+
20+
export function useQuizFilters(authenticatedUserEmail?: string | null) {
21+
// Default filter excludes quizzes completed by the authenticated user
22+
const initialFilters: QuizFilters = {
23+
...DEFAULT_FILTERS,
24+
excludedUserEmails: authenticatedUserEmail ? [authenticatedUserEmail] : [],
25+
};
26+
27+
const [filters, setFilters] = useState<QuizFilters | undefined>(undefined);
28+
29+
const settings$ = liveQuery(() => db.settings.toArray());
30+
31+
useEffect(() => {
32+
const subscription = settings$.subscribe(async (settings) => {
33+
// Check if we have a stored user
34+
const storedUserSetting = settings.find((setting) => setting.name === CURRENT_USER_KEY);
35+
const storedUserEmail = storedUserSetting ? storedUserSetting.value : null;
36+
37+
// If the authenticated user has changed or no user is stored yet
38+
if (authenticatedUserEmail && storedUserEmail !== authenticatedUserEmail) {
39+
// User has changed or first login - clear settings and store new user
40+
await db.settings.clear();
41+
42+
// Store the new user
43+
await db.settings.add({
44+
name: CURRENT_USER_KEY,
45+
value: authenticatedUserEmail,
46+
});
47+
48+
// Initialize excludedUserEmails setting
49+
await db.settings.add({
50+
name: EXCLUDED_USER_EMAILS_KEY,
51+
value: JSON.stringify([authenticatedUserEmail]),
52+
});
53+
54+
// Initialize isFilteringOnIllegible setting
55+
await db.settings.add({
56+
name: IS_FILTERING_ON_ILLEGIBLE_KEY,
57+
value: String(initialFilters.isFilteringOnIllegible),
58+
});
59+
60+
// Set filters directly to avoid delay in UI update
61+
setFilters({
62+
excludedUserEmails: [authenticatedUserEmail],
63+
isFilteringOnIllegible: initialFilters.isFilteringOnIllegible,
64+
});
65+
66+
return;
67+
}
68+
69+
// If settings are empty (for any other reason)
70+
if (settings.length === 0 || !settings.some((s) => s.name === EXCLUDED_USER_EMAILS_KEY)) {
71+
if (authenticatedUserEmail) {
72+
// Initialize excludedUserEmails setting
73+
await db.settings.add({
74+
name: EXCLUDED_USER_EMAILS_KEY,
75+
value: JSON.stringify([authenticatedUserEmail]),
76+
});
77+
78+
// Initialize isFilteringOnIllegible setting
79+
await db.settings.add({
80+
name: IS_FILTERING_ON_ILLEGIBLE_KEY,
81+
value: String(initialFilters.isFilteringOnIllegible),
82+
});
83+
84+
// Store current user if not already stored
85+
if (!settings.some((s) => s.name === CURRENT_USER_KEY)) {
86+
await db.settings.add({
87+
name: CURRENT_USER_KEY,
88+
value: authenticatedUserEmail,
89+
});
90+
}
91+
92+
// Set filters directly to avoid delay in UI update
93+
setFilters({
94+
excludedUserEmails: [authenticatedUserEmail],
95+
isFilteringOnIllegible: initialFilters.isFilteringOnIllegible,
96+
});
97+
98+
return;
99+
}
100+
}
101+
102+
const excludedUserEmailsSetting = settings.find((setting) => setting.name === EXCLUDED_USER_EMAILS_KEY);
103+
let excludedUserEmails = initialFilters.excludedUserEmails;
104+
105+
if (excludedUserEmailsSetting) {
106+
try {
107+
excludedUserEmails = JSON.parse(excludedUserEmailsSetting.value);
108+
} catch (e) {
109+
console.error('Failed to parse excluded user emails', e);
110+
}
111+
} else {
112+
await db.settings.add({
113+
name: EXCLUDED_USER_EMAILS_KEY,
114+
value: JSON.stringify(initialFilters.excludedUserEmails),
115+
});
116+
}
117+
118+
const isFilteringOnIllegibleSetting = settings.find((setting) => setting.name === IS_FILTERING_ON_ILLEGIBLE_KEY);
119+
let isFilteringOnIllegible = initialFilters.isFilteringOnIllegible;
120+
121+
if (isFilteringOnIllegibleSetting) {
122+
isFilteringOnIllegible = isFilteringOnIllegibleSetting.value === 'true';
123+
} else {
124+
await db.settings.add({
125+
name: IS_FILTERING_ON_ILLEGIBLE_KEY,
126+
value: String(initialFilters.isFilteringOnIllegible),
127+
});
128+
}
129+
130+
setFilters({
131+
excludedUserEmails,
132+
isFilteringOnIllegible,
133+
});
134+
});
135+
136+
return () => {
137+
subscription.unsubscribe();
138+
};
139+
}, [settings$, initialFilters]);
140+
141+
const updateFilters = async (changes: Partial<QuizFilters>) => {
142+
if (changes.excludedUserEmails !== undefined) {
143+
await db.settings.put({
144+
name: EXCLUDED_USER_EMAILS_KEY,
145+
value: JSON.stringify(changes.excludedUserEmails),
146+
});
147+
}
148+
149+
if (changes.isFilteringOnIllegible !== undefined) {
150+
await db.settings.put({
151+
name: IS_FILTERING_ON_ILLEGIBLE_KEY,
152+
value: String(changes.isFilteringOnIllegible),
153+
});
154+
}
155+
};
156+
157+
return {
158+
filters: filters ?? initialFilters,
159+
updateFilters,
160+
loading: filters === undefined,
161+
};
162+
}

src/pages/list/QuizList.tsx

Lines changed: 33 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useNavigate } from 'react-router-dom';
22

33
import { Fragment } from 'preact';
4-
import { useState } from 'preact/hooks';
54

65
import { useQuizlord } from '../../QuizlordProvider';
76
import Button from '../../components/Button';
@@ -14,10 +13,10 @@ import {
1413
formatDateTimeShortTime,
1514
userIdentifier,
1615
} from '../../helpers';
16+
import { useQuizFilters } from '../../hooks/useQuizFilters';
1717
import { useQuizList } from '../../hooks/useQuizList.hook';
1818
import { User } from '../../types/user';
1919
import { QuizListControls } from './QuizListControls';
20-
import { QuizFilters } from './quizFilters';
2120

2221
interface QuizCompletion {
2322
completedAt: string;
@@ -34,61 +33,42 @@ interface Node {
3433

3534
export default function QuizList() {
3635
const { user: authenticatedUser } = useQuizlord();
37-
const [quizFilters, setQuizFilters] = useState<QuizFilters>({
38-
excludedUserEmails: authenticatedUser?.email ? [authenticatedUser?.email] : [],
39-
isFilteringOnIllegible: true,
40-
});
41-
const { data, loading, fetchMore, refetch } = useQuizList(
42-
quizFilters.excludedUserEmails,
43-
quizFilters.isFilteringOnIllegible,
44-
);
36+
const { filters, updateFilters, loading: filtersLoading } = useQuizFilters(authenticatedUser?.email);
37+
38+
const { data, loading, fetchMore, refetch } = useQuizList(filters.excludedUserEmails, filters.isFilteringOnIllegible);
4539
const navigate = useNavigate();
4640

4741
return (
4842
<>
4943
<QuizListControls
5044
className='flex-1'
51-
filters={quizFilters}
52-
onFiltersChanged={(changes) =>
53-
setQuizFilters((prevState) => ({
54-
...prevState,
55-
...changes,
56-
}))
57-
}
45+
filters={filters}
46+
onFiltersChanged={(changes) => updateFilters(changes)}
5847
onRefreshClicked={() => refetch()}
5948
/>
60-
<div>
61-
<Table className='table-fixed'>
62-
<Table.Head>
63-
<Table.Row className='hidden lg:table-row' isHeader>
64-
<Table.HeaderCell>Quiz Date</Table.HeaderCell>
65-
<Table.HeaderCell>Type</Table.HeaderCell>
66-
<Table.HeaderCell>Uploaded By</Table.HeaderCell>
67-
<Table.HeaderCell>Completed</Table.HeaderCell>
68-
<Table.HeaderCell>Result</Table.HeaderCell>
69-
</Table.Row>
70-
<Table.Row className='lg:hidden' isHeader>
71-
<Table.HeaderCell>Quiz Details</Table.HeaderCell>
72-
<Table.HeaderCell>Completion</Table.HeaderCell>
73-
</Table.Row>
74-
</Table.Head>
75-
<Table.Body>
76-
{loading && (
77-
<>
78-
<Table.Row className='hidden lg:table-row'>
79-
<Table.Cell colSpan={5}>
80-
<Loader message='Loading available quizzes...' />
81-
</Table.Cell>
82-
</Table.Row>
83-
<Table.Row className='lg:hidden'>
84-
<Table.Cell colSpan={2}>
85-
<Loader message='Loading available quizzes...' />
86-
</Table.Cell>
87-
</Table.Row>
88-
</>
89-
)}
90-
{!loading &&
91-
data.quizzes.edges.map(({ node }: { node: Node }) => (
49+
{(loading || filtersLoading) && (
50+
<div className='flex justify-center my-4'>
51+
<Loader message='Loading...' />
52+
</div>
53+
)}
54+
{!loading && !filtersLoading && (
55+
<div>
56+
<Table className='table-fixed'>
57+
<Table.Head>
58+
<Table.Row className='hidden lg:table-row' isHeader>
59+
<Table.HeaderCell>Quiz Date</Table.HeaderCell>
60+
<Table.HeaderCell>Type</Table.HeaderCell>
61+
<Table.HeaderCell>Uploaded By</Table.HeaderCell>
62+
<Table.HeaderCell>Completed</Table.HeaderCell>
63+
<Table.HeaderCell>Result</Table.HeaderCell>
64+
</Table.Row>
65+
<Table.Row className='lg:hidden' isHeader>
66+
<Table.HeaderCell>Quiz Details</Table.HeaderCell>
67+
<Table.HeaderCell>Completion</Table.HeaderCell>
68+
</Table.Row>
69+
</Table.Head>
70+
<Table.Body>
71+
{data.quizzes.edges.map(({ node }: { node: Node }) => (
9272
<Fragment key={node.id}>
9373
<Table.Row className='hidden lg:table-row' isHoverable onClick={() => navigate(`/quiz/${node.id}`)}>
9474
<Table.Cell>{formatDate(node.date)}</Table.Cell>
@@ -119,9 +99,10 @@ export default function QuizList() {
11999
</Table.Row>
120100
</Fragment>
121101
))}
122-
</Table.Body>
123-
</Table>
124-
</div>
102+
</Table.Body>
103+
</Table>
104+
</div>
105+
)}
125106
{data?.quizzes?.pageInfo?.hasNextPage && (
126107
<div className='flex justify-center items-center mt-4'>
127108
<Button onClick={() => fetchMore({ variables: { after: data.quizzes.pageInfo.endCursor } })}>

src/pages/list/QuizListControls.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { useState } from 'preact/hooks';
55
import { useQuizlord } from '../../QuizlordProvider';
66
import Button from '../../components/Button';
77
import { userIdentifier } from '../../helpers';
8+
import { QuizFilters } from '../../hooks/useQuizFilters';
89
import { UserSelectorWithLoader } from './UserSelectorWithLoader';
9-
import { QuizFilters } from './quizFilters';
1010

1111
export interface QuizListControlsProps {
1212
filters: QuizFilters;
13-
onFiltersChanged: (filterChanges: QuizFilters) => void;
13+
onFiltersChanged: (filterChanges: Partial<QuizFilters>) => void;
1414
onRefreshClicked: () => void;
1515
className?: string;
1616
}
@@ -37,7 +37,6 @@ export function QuizListControls({ filters, onFiltersChanged, onRefreshClicked,
3737
className={`cursor-pointer${className ? ` ${className}` : ''}`}
3838
onClick={() =>
3939
onFiltersChanged({
40-
...filters,
4140
isFilteringOnIllegible: !filters.isFilteringOnIllegible,
4241
})
4342
}
@@ -74,7 +73,6 @@ export function QuizListControls({ filters, onFiltersChanged, onRefreshClicked,
7473
<Button
7574
onClick={() => {
7675
onFiltersChanged({
77-
...filters,
7876
excludedUserEmails: [...pendingSelections, ...(authenticatedUser ? [authenticatedUser.email] : [])],
7977
});
8078
setIsSelectingUsers(false);
@@ -86,7 +84,6 @@ export function QuizListControls({ filters, onFiltersChanged, onRefreshClicked,
8684
warning
8785
onClick={() => {
8886
onFiltersChanged({
89-
...filters,
9087
excludedUserEmails: [],
9188
});
9289
setIsSelectingUsers(false);

src/pages/list/quizFilters.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)