From 44f6e0453e6a9faebc12af829d212baf7014930c Mon Sep 17 00:00:00 2001 From: "codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie)" Date: Sun, 2 Aug 2026 18:21:05 +0000 Subject: [PATCH] Add userinput status filters Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- packages/ui/src/app.css | 20 +++++ .../routes/p/[project]/userinput/+page.svelte | 68 +++++++++++++++- .../userinput/userinput-page.svelte.test.ts | 77 +++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index a63b29b..8474fee 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -345,6 +345,26 @@ a { color: var(--accent); text-underline-offset: 2px; } block under it is the ragged edge Things never has. */ .drawer-pad .sec-head { max-width: 64ch; } +/* A foreign board's vocabulary is useful for narrowing, not a Radial workflow to decorate. These + stay neutral and compact; selection is the ordinary accent, and every state remains written out. */ +.status-filters { + display: flex; flex-wrap: wrap; gap: 5px; margin: 9px 0 12px; +} +.status-filters button { + display: inline-flex; align-items: center; gap: 5px; + border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; + background: var(--paper); color: var(--ink-2); cursor: pointer; + font: 500 11.5px/1.2 var(--sans); + transition: background 110ms ease, border-color 110ms ease, color 110ms ease; +} +.status-filters button:hover { background: var(--hover); border-color: var(--stroke); color: var(--ink); } +.status-filters button.active { + background: var(--accent-wash); border-color: color-mix(in oklch, var(--accent) 30%, var(--line)); + color: var(--accent); font-weight: 600; +} +.status-filters button span { font-variant-numeric: tabular-nums; opacity: 0.75; } +.empty.compact { margin-top: 6px; } + /* ─── rows (goal list) ───────────────────────────────────────────────────── */ .list { list-style: none; margin: 0; padding: 0; } .goal-row { diff --git a/packages/ui/src/routes/p/[project]/userinput/+page.svelte b/packages/ui/src/routes/p/[project]/userinput/+page.svelte index 726901e..d0c3c9a 100644 --- a/packages/ui/src/routes/p/[project]/userinput/+page.svelte +++ b/packages/ui/src/routes/p/[project]/userinput/+page.svelte @@ -41,6 +41,19 @@ !space.fixture && account.status === 'signed-in' && participates(space.directory, account.did), ) + const statusFilters = [ + 'none', + 'open', + 'under-review', + 'backlog', + 'planned', + 'in-progress', + 'implemented', + 'declined', + 'duplicate', + 'closed', + ] as const + let found = $state([]) /** * The board's feedback, joined to this space's own goals only here, at the point of drawing. @@ -50,6 +63,19 @@ * function of the board's address alone. */ const rows = $derived(feedbackRows(space.index, found)) + let selectedStatuses = $state([]) + const statusCounts = $derived( + rows.reduce((counts, row) => { + const status = row.status?.state ?? 'none' + counts.set(status, (counts.get(status) ?? 0) + 1) + return counts + }, new Map()), + ) + const visibleRows = $derived( + selectedStatuses.length === 0 + ? rows + : rows.filter((row) => selectedStatuses.includes(row.status?.state ?? 'none')), + ) let name = $state('') let failed = $state(0) let truncated = $state(false) @@ -107,6 +133,12 @@ if (opened !== row.uri) editing = '' } + function toggleStatus(status: string): void { + selectedStatuses = selectedStatuses.includes(status) + ? selectedStatuses.filter((selected) => selected !== status) + : [...selectedStatuses, status] + } + function compose(row: FeedbackRow): void { writeError = '' const args = importArgs(project!, row, name) @@ -222,9 +254,40 @@

{:else} -
Feedback {rows.length}
+
+ Feedback + + {visibleRows.length}{selectedStatuses.length ? ` of ${rows.length}` : ''} + +
+
+ + {#each statusFilters as status} + + {/each} +
+ {#if visibleRows.length === 0} +
+

No feedback has the selected {selectedStatuses.length === 1 ? 'status' : 'statuses'}.

+ +
+ {:else}
- {#each rows as row (row.uri)} + {#each visibleRows as row (row.uri)} {@const author = space.directory.get(row.did)}
{/each}
+ {/if} {/if} {/if} diff --git a/packages/ui/src/routes/p/[project]/userinput/userinput-page.svelte.test.ts b/packages/ui/src/routes/p/[project]/userinput/userinput-page.svelte.test.ts index 35bdf51..58ab69f 100644 --- a/packages/ui/src/routes/p/[project]/userinput/userinput-page.svelte.test.ts +++ b/packages/ui/src/routes/p/[project]/userinput/userinput-page.svelte.test.ts @@ -276,6 +276,83 @@ describe('the userinput section', () => { expect(host.querySelector('.row .vchip')?.textContent?.trim()).toBe('planned') }) + it('filters by one or several board statuses, including feedback with no status', async () => { + discover.mockResolvedValue( + found([ + row(), + row({ + uri: `at://${AUTHOR}/app.userinput.discussion/planned`, + liveTitle: 'Planned work', + status: { + uri: 'at://did:plc:owner/app.userinput.status/planned', + did: 'did:plc:owner', + subject: { uri: `at://${AUTHOR}/app.userinput.discussion/planned`, cid: 'cid-one' }, + state: 'planned', + createdAt: '2026-07-31T00:00:00Z', + }, + }), + row({ + uri: `at://${AUTHOR}/app.userinput.discussion/done`, + liveTitle: 'Shipped work', + status: { + uri: 'at://did:plc:owner/app.userinput.status/done', + did: 'did:plc:owner', + subject: { uri: `at://${AUTHOR}/app.userinput.discussion/done`, cid: 'cid-one' }, + state: 'implemented', + createdAt: '2026-07-31T00:00:00Z', + }, + }), + ]), + ) + await render() + + const filter = (label: string): HTMLButtonElement | undefined => + [...host.querySelectorAll('.status-filters button')].find((button) => + button.textContent?.trim().startsWith(label), + ) + + expect(rows()).toHaveLength(3) + expect(filter('none')?.textContent).toContain('1') + expect(filter('planned')?.textContent).toContain('1') + filter('planned')?.click() + flushSync() + expect(rows()).toHaveLength(1) + expect(text()).toContain('Planned work') + expect(text()).not.toContain('Shipped work') + expect(filter('planned')?.getAttribute('aria-pressed')).toBe('true') + + filter('implemented')?.click() + flushSync() + expect(rows()).toHaveLength(2) + expect(text()).toContain('Planned work') + expect(text()).toContain('Shipped work') + + filter('all')?.click() + flushSync() + expect(rows()).toHaveLength(3) + expect(filter('all')?.getAttribute('aria-pressed')).toBe('true') + }) + + it('offers a clear reset when a selected status has no feedback', async () => { + discover.mockResolvedValue(found([row()])) + await render() + + const implemented = [...host.querySelectorAll('.status-filters button')].find( + (button) => button.textContent?.includes('implemented'), + ) + implemented?.click() + flushSync() + + expect(rows()).toHaveLength(0) + expect(text()).toContain('No feedback has the selected status') + const reset = [...host.querySelectorAll('button')].find((button) => + button.textContent?.includes('Show all feedback'), + ) + reset?.click() + flushSync() + expect(rows()).toHaveLength(1) + }) + it('sends a project with no board connected to the settings that connect one', async () => { project = projectWith([]) discover.mockResolvedValue(found([])) -- 2.51.2