feat(trash): zustand store — showTrash/trashNotes/trashCount + 5 actions (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
interface InboxState {
|
||||
notes: Note[];
|
||||
trashNotes: Note[];
|
||||
trashCount: number;
|
||||
showTrash: boolean;
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
@@ -17,6 +20,11 @@ interface InboxState {
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -26,6 +34,9 @@ const emptyContinuity: WeeklyContinuity = {
|
||||
|
||||
export const useInbox = create<InboxState>((set, get) => ({
|
||||
notes: [],
|
||||
trashNotes: [],
|
||||
trashCount: 0,
|
||||
showTrash: false,
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
@@ -34,38 +45,78 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
tagFilter: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
|
||||
},
|
||||
upsertNote(note) {
|
||||
const i = get().notes.findIndex((n) => n.id === note.id);
|
||||
if (i >= 0) {
|
||||
const next = get().notes.slice();
|
||||
next[i] = note;
|
||||
set({ notes: next });
|
||||
if (note.deletedAt !== null) {
|
||||
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
|
||||
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
|
||||
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
|
||||
const nextTrash = get().trashNotes.slice();
|
||||
if (ti >= 0) nextTrash[ti] = note;
|
||||
else nextTrash.unshift(note);
|
||||
set({ notes: cleanNotes, trashNotes: nextTrash, trashCount: nextTrash.length });
|
||||
} else {
|
||||
set({ notes: [note, ...get().notes] });
|
||||
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
|
||||
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
|
||||
const i = get().notes.findIndex((n) => n.id === note.id);
|
||||
const nextNotes = get().notes.slice();
|
||||
if (i >= 0) nextNotes[i] = note;
|
||||
else nextNotes.unshift(note);
|
||||
set({ notes: nextNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length });
|
||||
}
|
||||
},
|
||||
removeNote(id) {
|
||||
set({ notes: get().notes.filter((n) => n.id !== id) });
|
||||
const cleanNotes = get().notes.filter((n) => n.id !== id);
|
||||
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
|
||||
set({ notes: cleanNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length });
|
||||
},
|
||||
setTagFilter(tag) {
|
||||
set({ tagFilter: tag });
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
const next = !get().showTrash;
|
||||
set({ showTrash: next });
|
||||
if (next) await get().loadTrash();
|
||||
},
|
||||
async loadTrash() {
|
||||
const trashNotes = await inboxApi.listTrash({ limit: 200 });
|
||||
set({ trashNotes, trashCount: trashNotes.length });
|
||||
},
|
||||
async restoreNote(id) {
|
||||
await inboxApi.restoreNote(id);
|
||||
// onNoteUpdated 이벤트 미수신 케이스 대비 (renderer 자가 갱신)
|
||||
const note = get().trashNotes.find((n) => n.id === id);
|
||||
if (note) {
|
||||
get().upsertNote({ ...note, deletedAt: null });
|
||||
}
|
||||
},
|
||||
async permanentDeleteNote(id) {
|
||||
const r = await inboxApi.permanentDeleteNote(id);
|
||||
if (r.confirmed) get().removeNote(id);
|
||||
},
|
||||
async emptyTrash() {
|
||||
const r = await inboxApi.emptyTrash();
|
||||
if (r.confirmed) {
|
||||
set({ trashNotes: [], trashCount: 0 });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
90
tests/unit/store.trash.test.ts
Normal file
90
tests/unit/store.trash.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
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),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
||||
id, rawText: 'x',
|
||||
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
describe('useInbox — trash state (v0.2.3 #4)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
|
||||
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().toggleShowTrash();
|
||||
expect(useInbox.getState().showTrash).toBe(true);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
expect(mockApi.listTrash).toHaveBeenCalled();
|
||||
await useInbox.getState().toggleShowTrash();
|
||||
expect(useInbox.getState().showTrash).toBe(false);
|
||||
});
|
||||
|
||||
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
expect(useInbox.getState().notes).toHaveLength(0);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a'));
|
||||
expect(useInbox.getState().notes).toHaveLength(1);
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
expect(useInbox.getState().notes).toHaveLength(0);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restoreNote calls api', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
await useInbox.getState().restoreNote('a');
|
||||
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
|
||||
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
await useInbox.getState().emptyTrash();
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user