diff --git a/packages/core/test/validation.test.mjs b/packages/core/test/validation.test.mjs index 25f2d9e..f749a9d 100644 --- a/packages/core/test/validation.test.mjs +++ b/packages/core/test/validation.test.mjs @@ -3,12 +3,19 @@ import { readFile } from 'node:fs/promises' import { describe, it } from 'node:test' import { resolve } from 'node:path' import { COLLECTIONS } from '../dist/generated/records.js' +import { normalizeViewName } from '../dist/index.js' import { normalizeArtifactTitle } from '../dist/turn-protocol.js' import { validateRecord } from '../dist/validation.js' import { GOAL_SOURCE_TANGLED_ISSUE } from '../dist/records.js' const fixtures = resolve(import.meta.dirname, 'fixtures') +describe('saved view names', () => { + it('uses the writer key after trimming and collapsing inner whitespace', () => { + assert.equal(normalizeViewName(' Infra work\n queue '), 'Infra work queue') + }) +}) + describe('record validation', () => { it('accepts a valid fixture for every collection', async () => { const records = JSON.parse(await readFile(resolve(fixtures, 'valid-records.json'), 'utf8')) diff --git a/packages/ui/src/lib/components/FilterBar.svelte b/packages/ui/src/lib/components/FilterBar.svelte index 608bec1..1eddad8 100644 --- a/packages/ui/src/lib/components/FilterBar.svelte +++ b/packages/ui/src/lib/components/FilterBar.svelte @@ -43,11 +43,11 @@ // // Two effects, and the direction each runs in matters. Arriving somewhere ADOPTS what the URL says; // touching a control WRITES what the state says. They cannot chase each other: the first is guarded - // on the place actually changing (a `replaceState` keeps the pathname, so it never re-fires), and - // the second compares the serialized forms before touching history. + // on the complete location (including a saved view's query), and the second compares serialized + // forms before touching history. Its own replacement parses back to the state it just wrote. let placed = '' $effect(() => { - const here = `${space.uri} ${page.url.pathname}` + const here = `${space.uri} ${page.url.pathname}${page.url.search}` if (here === placed) return placed = here setFilter(filterFromParams(page.url.searchParams)) diff --git a/packages/ui/src/lib/components/FilterBar.svelte.test.ts b/packages/ui/src/lib/components/FilterBar.svelte.test.ts new file mode 100644 index 0000000..93f6edd --- /dev/null +++ b/packages/ui/src/lib/components/FilterBar.svelte.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import type { GoalView } from '@radial/core' +import { flushSync, mount, unmount } from 'svelte' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { page } from '$app/state' +import { EMPTY_FILTER } from '$lib/filters.js' +import { setFilter } from '$lib/filters.svelte.js' +import type { Space } from '$lib/space.js' +import FilterBar from './FilterBar.svelte' + +// This needs a mounted component: the regression was between SvelteKit's reactive page URL and the +// component's two effects, not in URL serialization. A SvelteURL gives the mock the same reactive +// query changes that `$app/state` supplies in the browser. +vi.mock('$app/state', async () => { + const { SvelteURL } = await import('svelte/reactivity') + return { page: { url: new SvelteURL('https://radial.test/goals'), state: {} } } +}) +vi.mock('$app/navigation', () => ({ replaceState: vi.fn() })) +vi.mock('$lib/auth.svelte.js', () => ({ account: { status: 'signed-out' } })) +vi.mock('$lib/write.js', () => ({ write: vi.fn() })) + +const SPACE = 'at://did:plc:me/com.disnetdev.radial.space/space' +const goal = (labels: string[]): GoalView => ({ labels, ended: false }) as unknown as GoalView +const goals = [goal(['infra']), goal(['ux'])] +const space = { + uri: SPACE, + fixture: true, + index: { savedViews: [], goals }, + directory: { get: () => undefined }, +} as unknown as Space + +let host: HTMLElement +let component: Record | undefined + +beforeEach(() => { + setFilter(EMPTY_FILTER) + page.url.pathname = '/goals' + page.url.search = `?space=${encodeURIComponent(SPACE)}&label=infra&state=live` + host = document.createElement('div') + document.body.append(host) +}) + +afterEach(() => { + if (component) void unmount(component) + component = undefined + host.remove() +}) + +describe('filter URL synchronization', () => { + it('adopts a different saved-view query on the same pathname', () => { + component = mount(FilterBar, { target: host, props: { goals, space } }) + flushSync() + const chip = (label: string): HTMLButtonElement | undefined => + [...host.querySelectorAll('button.lchip')].find((button) => + button.textContent?.includes(label), + ) + expect(chip('infra')?.getAttribute('aria-pressed')).toBe('true') + + page.url.search = `?space=${encodeURIComponent(SPACE)}&label=ux&state=live` + flushSync() + + expect(chip('infra')?.getAttribute('aria-pressed')).toBe('false') + expect(chip('ux')?.getAttribute('aria-pressed')).toBe('true') + expect(page.url.searchParams.get('space')).toBe(SPACE) + }) +}) diff --git a/packages/ui/src/lib/filters.test.ts b/packages/ui/src/lib/filters.test.ts index bb20b21..d4afbad 100644 --- a/packages/ui/src/lib/filters.test.ts +++ b/packages/ui/src/lib/filters.test.ts @@ -5,7 +5,6 @@ import { applyFilter, filterFromParams, filterHref, - filterQuery, filterToParams, goalMatchesFilter, labelCorpus, @@ -68,7 +67,6 @@ describe('a filter in the URL', () => { }) it('writes nothing when nothing is narrowed', () => { - expect(filterQuery(EMPTY_FILTER)).toBe('') expect(filterToParams(EMPTY_FILTER).toString()).toBe('') }) @@ -84,10 +82,6 @@ describe('a filter in the URL', () => { expect(read).toEqual(EMPTY_FILTER) }) - it('is appendable to an href', () => { - expect(filterQuery(filter({ state: 'live' }))).toBe('?state=live') - }) - it('merges filter keys without discarding unrelated navigation context', () => { const href = filterHref('/goals?space=at%3A%2F%2Fs&unit=one&label=old', filter({ labels: ['infra', 'ux'], diff --git a/packages/ui/src/lib/filters.ts b/packages/ui/src/lib/filters.ts index 8fdd6da..e2cc87f 100644 --- a/packages/ui/src/lib/filters.ts +++ b/packages/ui/src/lib/filters.ts @@ -91,12 +91,6 @@ export function filterFromParams(params: URLSearchParams): GoalFilter { } } -/** `?a=b` for a filter, or `''` when it narrows nothing — appendable to any href. */ -export function filterQuery(filter: GoalFilter): string { - const query = filterToParams(filter).toString() - return query === '' ? '' : `?${query}` -} - /** Merge a filter into an href without disturbing navigation context such as `space` or `unit`. */ export function filterHref(href: string, filter: GoalFilter): string { const [path, query = ''] = href.split('?') diff --git a/packages/ui/src/lib/views.test.ts b/packages/ui/src/lib/views.test.ts index 9814d9e..e31b64a 100644 --- a/packages/ui/src/lib/views.test.ts +++ b/packages/ui/src/lib/views.test.ts @@ -19,7 +19,7 @@ const goal = (labels: string[], ended = false, project = 'at://p/1'): GoalView = ({ labels, ended, target: { value: { project: { uri: project } } } }) as unknown as GoalView const indexWith = (views: SavedViewView[], goals: GoalView[] = []): MaterializedIndex => - ({ savedViews: views, goals }) as MaterializedIndex + ({ savedViews: views, goals, projects: [] }) as unknown as MaterializedIndex describe('a view as a filter', () => { it('is the filter, not a second reading of one', () => { @@ -67,6 +67,7 @@ describe('which views are mine', () => { // would claim a re-save was going to overwrite something it would in fact sit beside. expect(findView(index, TIM, 'infra')).toBeUndefined() expect(findView(index, TIM, ' Infra\n ')?.name).toBe('Infra') + expect(findView(indexWith([view({ name: 'Infra work' })]), TIM, 'Infra work')?.name).toBe('Infra work') }) it('reads the fixture space, where the views are Tim personal records', () => { @@ -88,6 +89,29 @@ describe('the href that applies a view', () => { expect(params.getAll('label')).toEqual(['infra']) expect(params.get('state')).toBe('live') }) + + it('opens a live pinned project and removes the redundant project filter', () => { + const space = buildFixtureSpace() + const project = space.index.projects[0]! + const href = viewHref( + space.index, + space.uri, + view({ labels: ['infra'], state: 'ended', project: project.target }), + ) + const parsed = new URL(href, 'https://radl.app') + expect(parsed.pathname).toBe(`/p/${encodeURIComponent(project.name)}`) + expect(parsed.searchParams.get('space')).toBe(space.uri) + expect(parsed.searchParams.getAll('label')).toEqual(['infra']) + expect(parsed.searchParams.get('state')).toBe('ended') + expect(parsed.searchParams.has('project')).toBe(false) + }) + + it('falls back to the space-wide list when its pinned project is no longer live', () => { + const href = viewHref(indexWith([]), SPACE, view({ project: { uri: 'at://p/gone', cid: 'c' } })) + const parsed = new URL(href, 'https://radl.app') + expect(parsed.pathname).toBe('/goals') + expect(parsed.searchParams.get('project')).toBe('at://p/gone') + }) }) describe('the argv that saves and deletes a view', () => {