Skip to content

Commit a3fbe61

Browse files
authored
Merge pull request #1748 from AtCoder-NoviSteps/#1726
🎨 Allow saving and restoring of showing/hiding of replenishment workbook for curriculum (#1726)
2 parents f1d6004 + 75d9530 commit a3fbe61

File tree

3 files changed

+176
-45
lines changed

3 files changed

+176
-45
lines changed

src/lib/components/WorkBooks/WorkBookList.svelte

Lines changed: 53 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { ButtonGroup, Button, Toggle } from 'svelte-5-ui-lib';
55
66
import { taskGradesByWorkBookTypeStore } from '$lib/stores/task_grades_by_workbook_type';
7+
import { replenishmentWorkBooksStore } from '$lib/stores/replenishment_workbook.svelte';
78
import { canRead } from '$lib/utils/authorship';
89
import { WorkBookType, type WorkbookList, type WorkbooksList } from '$lib/types/workbook';
910
import { getTaskGradeLabel } from '$lib/utils/task';
@@ -57,8 +58,6 @@
5758
}),
5859
);
5960
60-
let isShowReplenishment: boolean = $state(false);
61-
6261
function countReadableWorkbooks(workbooks: WorkbooksList): number {
6362
const results = workbooks.reduce((count, workbook: WorkbookList) => {
6463
const hasReadPermission = canRead(workbook.isPublished, userId, workbook.authorId);
@@ -103,27 +102,21 @@
103102
<!-- TODO: 「ユーザ作成」の問題集には、検索機能を追加 -->
104103
{#if workbookType === WorkBookType.CURRICULUM}
105104
<div class="mb-6">
106-
<div class="flex flex-col md:flex-row items-start md:items-center justify-between">
107-
<div class="flex items-center space-x-4">
108-
<ButtonGroup>
109-
{#each AVAILABLE_GRADES as grade}
110-
<Button
111-
onclick={() => filterByGradeMode(grade)}
112-
class={selectedGrade === grade ? 'text-primary-700' : 'text-gray-900'}
113-
>
114-
{getTaskGradeLabel(grade)}
115-
</Button>
116-
{/each}
117-
</ButtonGroup>
118-
119-
<TooltipWrapper
120-
tooltipContent="問題集のグレードを指定します(最頻値。2つ以上ある場合は、最も易しいグレードに掲載)"
121-
/>
122-
</div>
123-
124-
<div class="mt-4 md:mt-0">
125-
<Toggle bind:checked={isShowReplenishment}>「補充」があれば表示</Toggle>
126-
</div>
105+
<div class="flex items-center space-x-4">
106+
<ButtonGroup>
107+
{#each AVAILABLE_GRADES as grade}
108+
<Button
109+
onclick={() => filterByGradeMode(grade)}
110+
class={selectedGrade === grade ? 'text-primary-700' : 'text-gray-900'}
111+
>
112+
{getTaskGradeLabel(grade)}
113+
</Button>
114+
{/each}
115+
</ButtonGroup>
116+
117+
<TooltipWrapper
118+
tooltipContent="問題集のグレードを指定します(最頻値。2つ以上ある場合は、最も易しいグレードに掲載)"
119+
/>
127120
</div>
128121
</div>
129122
{/if}
@@ -144,31 +137,46 @@
144137
/>
145138
</div>
146139

147-
<!-- カリキュラムの場合、かつ、公開されている【補充】問題集があるときだけ表示 -->
148-
{#if workbookType === WorkBookType.CURRICULUM && readableReplenishedWorkbooksCount() && isShowReplenishment}
140+
<!-- カリキュラム、かつ、公開されている【補充】問題集があるときのみ -->
141+
{#if workbookType === WorkBookType.CURRICULUM && readableReplenishedWorkbooksCount()}
149142
<div class="mt-12">
150-
<div class="flex items-center space-x-3 pb-4">
151-
<div class="text-2xl dark:text-white">補充</div>
152-
153-
<LabelWithTooltips
154-
labelName=""
155-
tooltipId="tooltip-for-replenished-workbooks"
156-
tooltipContents={[
157-
'(任意)',
158-
'特定の課題を持つ人向けの問題集です。',
159-
'苦手意識があれば、挑戦してみましょう。',
160-
]}
161-
/>
143+
<!-- 見出しと説明文、表示の切り替え用ボタンを常に表示 -->
144+
<div class="flex flex-col md:flex-row items-start md:items-center md:space-x-6">
145+
<div class="flex items-center space-x-1 pb-0 md:pb-4">
146+
<div class="text-2xl dark:text-white">補充</div>
147+
148+
<LabelWithTooltips
149+
labelName=""
150+
tooltipId="tooltip-for-replenished-workbooks"
151+
tooltipContents={[
152+
'(任意)',
153+
'特定の課題(数学的素養や実装力など)を持つ人向けの問題集です。',
154+
'苦手意識があれば、挑戦してみましょう。',
155+
]}
156+
/>
157+
</div>
158+
159+
<div class="mt-4 md:mt-0 pb-4">
160+
<Toggle
161+
checked={replenishmentWorkBooksStore.canView()}
162+
onclick={replenishmentWorkBooksStore.toggleView}
163+
aria-label="Toggle visibility of replenishment workbooks for curriculum"
164+
>
165+
問題集を表示
166+
</Toggle>
167+
</div>
162168
</div>
163169

164-
<WorkBookBaseTable
165-
{workbookType}
166-
workbooks={replenishedWorkbooks}
167-
{workbookGradeModes}
168-
{userId}
169-
{role}
170-
taskResults={taskResultsWithWorkBookId}
171-
/>
170+
{#if replenishmentWorkBooksStore.canView()}
171+
<WorkBookBaseTable
172+
{workbookType}
173+
workbooks={replenishedWorkbooks}
174+
{workbookGradeModes}
175+
{userId}
176+
{role}
177+
taskResults={taskResultsWithWorkBookId}
178+
/>
179+
{/if}
172180
</div>
173181
{/if}
174182
{:else}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// See:
2+
// https://svelte.dev/docs/kit/$app-environment#browser
3+
import { browser } from '$app/environment';
4+
5+
// See:
6+
// https://svelte.dev/docs/svelte/stores
7+
// https://svelte.dev/docs/svelte/$state
8+
const IS_SHOWN_REPLENISHMENT_WORKBOOKS = 'is_shown_replenishment_workbooks';
9+
10+
class ReplenishmentWorkBooksStore {
11+
private isShown = $state<boolean>(this.loadInitialState());
12+
13+
// Note:
14+
// The $state only manages state in memory and is reset on page reload.
15+
// In addition, it is not linked to the browser's persistent storage.
16+
private loadInitialState(): boolean {
17+
// WHY: Cannot access localStorage during SSR (server-side rendering).
18+
if (!browser) {
19+
return false;
20+
}
21+
22+
const savedStatus = localStorage.getItem(IS_SHOWN_REPLENISHMENT_WORKBOOKS);
23+
24+
try {
25+
return savedStatus ? JSON.parse(savedStatus) : false;
26+
} catch (error) {
27+
console.warn('Failed to parse replenishment workbooks visibility state:', error);
28+
return false;
29+
}
30+
}
31+
32+
canView(): boolean {
33+
return this.isShown;
34+
}
35+
36+
toggleView(): void {
37+
this.isShown = !this.isShown;
38+
39+
if (browser) {
40+
localStorage.setItem(IS_SHOWN_REPLENISHMENT_WORKBOOKS, JSON.stringify(this.isShown));
41+
}
42+
}
43+
44+
reset() {
45+
this.isShown = false;
46+
}
47+
}
48+
49+
export const replenishmentWorkBooksStore = new ReplenishmentWorkBooksStore();
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect, test, vi } from 'vitest';
2+
3+
import { replenishmentWorkBooksStore } from '$lib/stores/replenishment_workbook.svelte';
4+
5+
vi.mock('$app/environment', () => ({
6+
browser: true,
7+
}));
8+
9+
describe('Replenishment workbooks store', () => {
10+
const localStorageKey = 'is_shown_replenishment_workbooks';
11+
const mockLocalStorage: Storage = {
12+
getItem: vi.fn(),
13+
setItem: vi.fn(),
14+
removeItem: vi.fn(),
15+
clear: vi.fn(),
16+
length: 0,
17+
key: vi.fn(),
18+
};
19+
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
// Setup mock for localStorage
23+
vi.stubGlobal('localStorage', mockLocalStorage);
24+
25+
replenishmentWorkBooksStore.reset();
26+
});
27+
28+
afterEach(() => {
29+
vi.unstubAllGlobals();
30+
});
31+
32+
test('expects to be invisible before toggling', () => {
33+
expect(replenishmentWorkBooksStore.canView()).toBeFalsy();
34+
});
35+
36+
test.each([
37+
{ toggles: 1, expected: true },
38+
{ toggles: 2, expected: false },
39+
{ toggles: 3, expected: true },
40+
{ toggles: 4, expected: false },
41+
])('expects to be $expected after toggling $toggles times', ({ toggles, expected }) => {
42+
for (let i = 1; i <= toggles; i++) {
43+
replenishmentWorkBooksStore.toggleView();
44+
}
45+
expect(replenishmentWorkBooksStore.canView()).toBe(expected);
46+
});
47+
48+
// Note: This test is skipped because it is not possible to mock localStorage in JSDOM.
49+
test.skip('persists state in localStorage', () => {
50+
(mockLocalStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify(false));
51+
52+
replenishmentWorkBooksStore.toggleView();
53+
54+
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1);
55+
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(localStorageKey, JSON.stringify(true));
56+
});
57+
58+
test('handles invalid localStorage data', () => {
59+
localStorage.setItem(localStorageKey, 'invalid-json');
60+
expect(replenishmentWorkBooksStore.canView()).toBeFalsy();
61+
});
62+
});
63+
64+
describe('Replenishment workbooks store in SSR', () => {
65+
beforeEach(() => {
66+
vi.mock('$app/environment', () => ({
67+
browser: false,
68+
}));
69+
});
70+
71+
test('handles SSR gracefully', () => {
72+
expect(replenishmentWorkBooksStore.canView()).toBeFalsy();
73+
});
74+
});

0 commit comments

Comments
 (0)