feat(store): notebooks state + actions (load/select/create/rename/delete/move/toggle)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
import type { InboxApi } from '@shared/types';
|
||||
import type { InboxApi, NotebookApi } from '@shared/types';
|
||||
export const inboxApi: InboxApi = window.inkling.inbox;
|
||||
export const notebookApi: NotebookApi = window.inkling.notebook;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import type { Note, Notebook, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi, notebookApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
@@ -45,6 +45,11 @@ interface InboxState {
|
||||
searchQuery: string;
|
||||
searchResults: Note[] | null; // null = 검색 안 한 상태
|
||||
reviewData: ReviewAggregate | null;
|
||||
// v0.4 — Notebook sidebar state.
|
||||
notebooks: Notebook[];
|
||||
selectedNotebookId: string | null;
|
||||
sidebarVisible: boolean;
|
||||
sidebarWidth: number;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -71,6 +76,15 @@ interface InboxState {
|
||||
searchNotes: (q: string) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
|
||||
// v0.4 — Notebook actions.
|
||||
loadNotebooks: () => Promise<void>;
|
||||
selectNotebook: (id: string) => void;
|
||||
createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -101,6 +115,10 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
reviewData: null,
|
||||
notebooks: [],
|
||||
selectedNotebookId: null,
|
||||
sidebarVisible: false,
|
||||
sidebarWidth: 240,
|
||||
async loadInitial() {
|
||||
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
|
||||
set({ loading: true });
|
||||
@@ -388,5 +406,58 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
console.error('[inbox] loadReview failed', period, e);
|
||||
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
|
||||
}
|
||||
},
|
||||
// v0.4 — Notebook actions.
|
||||
async loadNotebooks() {
|
||||
const notebooks = await notebookApi.list();
|
||||
const current = get().selectedNotebookId;
|
||||
// selectedNotebookId 가 null 이면 첫 notebook (가장 오래된 = 기본) 으로 설정.
|
||||
const selectedNotebookId = current === null && notebooks.length > 0
|
||||
? notebooks[0]!.id
|
||||
: current;
|
||||
set({ notebooks, selectedNotebookId });
|
||||
},
|
||||
selectNotebook(id) {
|
||||
set({ selectedNotebookId: id });
|
||||
},
|
||||
async createNotebook(name, color) {
|
||||
const r = await notebookApi.create({ name, color });
|
||||
if (r.ok) {
|
||||
set({ notebooks: [...get().notebooks, r.notebook] });
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async renameNotebook(id, name) {
|
||||
const r = await notebookApi.rename(id, name);
|
||||
if (r.ok) {
|
||||
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, name } : n) });
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async setNotebookColor(id, color) {
|
||||
await notebookApi.setColor(id, color);
|
||||
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, color } : n) });
|
||||
},
|
||||
async deleteNotebook(id) {
|
||||
const r = await notebookApi.delete(id);
|
||||
if (r.ok) {
|
||||
const remaining = get().notebooks.filter((n) => n.id !== id);
|
||||
const wasSelected = get().selectedNotebookId === id;
|
||||
set({
|
||||
notebooks: remaining,
|
||||
selectedNotebookId: wasSelected ? (remaining[0]?.id ?? null) : get().selectedNotebookId
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async moveNoteToNotebook(noteId, notebookId) {
|
||||
await notebookApi.moveNote(noteId, notebookId);
|
||||
await get().refreshMeta();
|
||||
},
|
||||
toggleSidebar() {
|
||||
set({ sidebarVisible: !get().sidebarVisible });
|
||||
}
|
||||
}));
|
||||
|
||||
77
tests/unit/store.notebook.test.ts
Normal file
77
tests/unit/store.notebook.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listTrash: vi.fn(async () => []),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
listByStatus: vi.fn(async () => [])
|
||||
},
|
||||
notebookApi: {
|
||||
list: vi.fn(async () => [
|
||||
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
|
||||
{ id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 1 }
|
||||
]),
|
||||
create: vi.fn(async (i: { name: string; color?: string }) => ({ ok: true as const, notebook: { id: 'nb-new', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } })),
|
||||
delete: vi.fn(async () => ({ ok: true as const })),
|
||||
rename: vi.fn(async () => ({ ok: true as const })),
|
||||
setColor: vi.fn(async () => ({ ok: true as const })),
|
||||
moveNote: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store.js';
|
||||
|
||||
describe('store notebooks', () => {
|
||||
beforeEach(() => {
|
||||
useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240 } as never);
|
||||
});
|
||||
|
||||
it('loadNotebooks 가 notebookApi.list 결과 반영', async () => {
|
||||
await useInbox.getState().loadNotebooks();
|
||||
expect(useInbox.getState().notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('loadNotebooks: selectedNotebookId null 이면 첫 notebook 으로 자동 설정', async () => {
|
||||
await useInbox.getState().loadNotebooks();
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-1');
|
||||
});
|
||||
|
||||
it('selectNotebook 가 selectedNotebookId 설정', () => {
|
||||
useInbox.getState().selectNotebook('nb-X');
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-X');
|
||||
});
|
||||
|
||||
it('createNotebook 성공 시 notebooks 에 추가', async () => {
|
||||
await useInbox.getState().createNotebook('학습', '#ccc');
|
||||
expect(useInbox.getState().notebooks.some((n) => n.name === '학습')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleSidebar 가 sidebarVisible 반전', () => {
|
||||
expect(useInbox.getState().sidebarVisible).toBe(false);
|
||||
useInbox.getState().toggleSidebar();
|
||||
expect(useInbox.getState().sidebarVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteNotebook 성공 시 notebooks 에서 제거 + selected 였으면 첫 notebook 으로', async () => {
|
||||
useInbox.setState({
|
||||
notebooks: [
|
||||
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 },
|
||||
{ id: 'nb-2', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }
|
||||
],
|
||||
selectedNotebookId: 'nb-2'
|
||||
} as never);
|
||||
await useInbox.getState().deleteNotebook('nb-2');
|
||||
expect(useInbox.getState().notebooks.map((n) => n.id)).toEqual(['nb-1']);
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user