diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index da3d8126..a78654ff 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -31,18 +31,24 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com return (isA && TYPE_ICON_MAP[isA]) || FileText } -const NOTE_STATUS_DOT: Record = { +type VisibleNoteStatus = Exclude + +const NOTE_STATUS_DOT: Record = { pendingSave: { color: 'var(--accent-green)', testId: 'pending-save-indicator', title: 'Saving to disk…' }, + unsaved: { color: 'var(--accent-green)', testId: 'unsaved-indicator', title: 'Saving to disk…' }, new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (uncommitted)' }, modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' }, } -function StatusDot({ noteStatus }: { noteStatus: NoteStatus }) { +function hasStatusDot(noteStatus: NoteStatus): noteStatus is VisibleNoteStatus { + return noteStatus !== 'clean' +} + +function StatusDot({ noteStatus }: { noteStatus: VisibleNoteStatus }) { const dot = NOTE_STATUS_DOT[noteStatus] - if (!dot) return null return ( - {noteStatus !== 'clean' && !isBinary && } + {hasStatusDot(noteStatus) && !isBinary && } {entry.title} {!isBinary && } diff --git a/src/components/NoteList.behavior.test.tsx b/src/components/NoteList.behavior.test.tsx index dea68d9c..e07743a6 100644 --- a/src/components/NoteList.behavior.test.tsx +++ b/src/components/NoteList.behavior.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { NoteList } from './NoteList' import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' +import type { NoteStatus } from '../types' describe('NoteList status indicators', () => { it('shows a modified indicator for modified notes', () => { @@ -40,6 +41,27 @@ describe('NoteList status indicators', () => { expect(screen.getAllByTestId('new-indicator')).toHaveLength(1) expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument() }) + + it('keeps the green indicator steady while a new note is edited and saved', () => { + const targetPath = mockEntries[0].path + const getNoteStatus = (noteStatus: NoteStatus) => (path: string) => path === targetPath ? noteStatus : 'clean' + const { props, rerender } = renderNoteList({ getNoteStatus: getNoteStatus('new') }) + const rerenderWithStatus = (nextStatus: NoteStatus) => { + rerender() + } + const expectSteadyIndicator = (testId: string) => { + const indicator = screen.getByTestId(testId) + expect(indicator).not.toHaveClass('tab-status-pulse') + } + + expectSteadyIndicator('new-indicator') + rerenderWithStatus('unsaved') + expectSteadyIndicator('unsaved-indicator') + rerenderWithStatus('pendingSave') + expectSteadyIndicator('pending-save-indicator') + rerenderWithStatus('new') + expectSteadyIndicator('new-indicator') + }) }) describe('NoteList virtualized datasets', () => { diff --git a/tests/smoke/note-status-indicator-stability.spec.ts b/tests/smoke/note-status-indicator-stability.spec.ts new file mode 100644 index 00000000..d21c43e5 --- /dev/null +++ b/tests/smoke/note-status-indicator-stability.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from '@playwright/test' +import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { triggerMenuCommand } from './testBridge' + +const STATUS_DOT_SELECTOR = [ + '[data-testid="new-indicator"]', + '[data-testid="unsaved-indicator"]', + '[data-testid="pending-save-indicator"]', +].join(',') + +interface StatusDotSample { + className: string | null + testId: string | null +} + +interface StatusDotSampleWindow { + __noteStatusDotSampler?: number + __noteStatusDotSamples?: StatusDotSample[] +} + +let tempVaultDir: string + +async function startStatusDotSampler(page: Page) { + await page.evaluate((selector) => { + const sampleWindow = window as typeof window & StatusDotSampleWindow + sampleWindow.__noteStatusDotSamples = [] + const sample = () => { + const dot = document.querySelector(selector) as HTMLElement | null + sampleWindow.__noteStatusDotSamples?.push({ + className: dot?.className ?? null, + testId: dot?.dataset.testid ?? null, + }) + } + + sample() + sampleWindow.__noteStatusDotSampler = window.setInterval(sample, 100) + }, STATUS_DOT_SELECTOR) +} + +async function stopStatusDotSampler(page: Page): Promise { + return page.evaluate(() => { + const sampleWindow = window as typeof window & StatusDotSampleWindow + if (sampleWindow.__noteStatusDotSampler !== undefined) { + window.clearInterval(sampleWindow.__noteStatusDotSampler) + sampleWindow.__noteStatusDotSampler = undefined + } + return sampleWindow.__noteStatusDotSamples ?? [] + }) +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVaultDesktopHarness(page, tempVaultDir) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('new note status indicator stays steady through typing and autosave', async ({ page }) => { + await triggerMenuCommand(page, APP_COMMAND_IDS.fileNewNote) + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, { + timeout: 5_000, + }) + await expect(page.locator(STATUS_DOT_SELECTOR).first()).toBeVisible({ timeout: 5_000 }) + + await startStatusDotSampler(page) + await page.locator('.bn-editor').click() + await page.keyboard.type('The sidebar status dot should stay steady while this note is edited. ', { + delay: 25, + }) + await page.waitForTimeout(1_800) + + const samples = await stopStatusDotSampler(page) + expect(samples.length).toBeGreaterThan(0) + expect(samples.filter((sample) => sample.testId === null)).toEqual([]) + expect(samples.some((sample) => sample.testId === 'unsaved-indicator')).toBe(true) + expect(samples.filter((sample) => sample.className?.includes('tab-status-pulse'))).toEqual([]) +})