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