From 634da8c30906d00023e6ce035a4ce47f02180150 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Jul 2026 13:13:06 -0700 Subject: [PATCH] feat: add mobile-first kanban board view for Chorus triage items --- frontend/src/components/KanbanBoard.vue | 269 ++++++++++++++++++++++++ frontend/tests/KanbanBoard.spec.js | 87 ++++++++ 2 files changed, 356 insertions(+) create mode 100644 frontend/src/components/KanbanBoard.vue create mode 100644 frontend/tests/KanbanBoard.spec.js diff --git a/frontend/src/components/KanbanBoard.vue b/frontend/src/components/KanbanBoard.vue new file mode 100644 index 0000000..0f3e0de --- /dev/null +++ b/frontend/src/components/KanbanBoard.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/frontend/tests/KanbanBoard.spec.js b/frontend/tests/KanbanBoard.spec.js new file mode 100644 index 0000000..e11476c --- /dev/null +++ b/frontend/tests/KanbanBoard.spec.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import KanbanBoard from '../src/components/KanbanBoard.vue' + +function makeItem(overrides = {}) { + return { + id: 1, + modality: 'bh_email', + sender_id: 'jane@example.com', + raw_content: 'I have some books to donate.', + type: null, + stage: 'new', + direction: 'inbound', + follow_up_date: '', + ...overrides, + } +} + +describe('KanbanBoard', () => { + it('buckets items into the correct board and shows counts on tabs', () => { + const items = [ + makeItem({ id: 1, type: 'donation', direction: 'inbound', stage: 'new' }), + makeItem({ id: 2, type: 'other', direction: 'inbound', stage: 'new' }), + makeItem({ id: 3, type: null, direction: 'outbound', stage: 'new' }), + makeItem({ id: 4, type: null, direction: 'inbound', stage: 'new' }), + ] + const wrapper = mount(KanbanBoard, { props: { items } }) + const tabs = wrapper.findAll('[role="tab"]').map((t) => t.text()) + expect(tabs).toEqual([ + 'Donations (1)', 'Other (1)', 'Outbound Leads (1)', 'Unsorted (1)', + ]) + }) + + it('defaults to the Donations board and its first stage', () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + const activeTab = wrapper.find('[role="tab"][aria-selected="true"]') + expect(activeTab.text()).toContain('Donations') + }) + + it('shows humanized stage labels, not raw stage strings, on column headers', () => { + const items = [makeItem({ id: 1, type: 'donation', stage: 'pickup_scheduled' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + expect(wrapper.text()).toContain('Pickup Scheduled') + expect(wrapper.text()).not.toContain('pickup_scheduled') + }) + + it('switching board tabs resets the active stage to that board\'s first stage', async () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + const outboundTab = wrapper.findAll('[role="tab"]').find((t) => t.text().includes('Outbound Leads')) + await outboundTab.trigger('click') + const breadcrumb = wrapper.find('.mobile-breadcrumb') + expect(breadcrumb.text()).toContain('New') + expect(breadcrumb.text()).toContain('Outbound Leads') + }) + + it('clicking a stage-strip button switches the active stage', async () => { + const items = [makeItem({ id: 1, type: 'donation', stage: 'replied' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + const repliedButton = wrapper.findAll('.stage-strip-button').find((b) => b.text().includes('Replied')) + await repliedButton.trigger('click') + expect(wrapper.find('.mobile-breadcrumb').text()).toContain('Replied') + }) + + it('clicking a card emits select-item with the item id', async () => { + const items = [makeItem({ id: 42, type: 'donation', stage: 'new' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + await wrapper.find('.card').trigger('click') + expect(wrapper.emitted('select-item')).toEqual([[42]]) + }) + + it('renders an empty-state message for a column with no items', () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + expect(wrapper.text()).toContain('Nothing here') + }) + + it('applies the is-active-mobile class only to the currently active stage column', () => { + const items = [ + makeItem({ id: 1, type: 'donation', stage: 'new' }), + makeItem({ id: 2, type: 'donation', stage: 'replied' }), + ] + const wrapper = mount(KanbanBoard, { props: { items } }) + const columns = wrapper.findAll('.column') + const activeColumns = columns.filter((c) => c.classes().includes('is-active-mobile')) + expect(activeColumns).toHaveLength(1) + expect(activeColumns[0].text()).toContain('New') + }) +})