Add mobile-first kanban board view for Chorus triage #3
2 changed files with 356 additions and 0 deletions
269
frontend/src/components/KanbanBoard.vue
Normal file
269
frontend/src/components/KanbanBoard.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import ModalityBadge from './ModalityBadge.vue'
|
||||||
|
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
|
||||||
|
|
||||||
|
const props = defineProps({ items: { type: Array, required: true } })
|
||||||
|
const emit = defineEmits(['select-item'])
|
||||||
|
|
||||||
|
const BOARDS = [
|
||||||
|
{ key: 'donation', label: 'Donations', stages: DONATION_STAGES },
|
||||||
|
{ key: 'other', label: 'Other', stages: OTHER_STAGES },
|
||||||
|
{ key: 'outbound', label: 'Outbound Leads', stages: OUTBOUND_STAGES },
|
||||||
|
{ key: 'unsorted', label: 'Unsorted', stages: UNSORTED_STAGES },
|
||||||
|
]
|
||||||
|
|
||||||
|
function bucketFor(item) {
|
||||||
|
if (item.direction === 'outbound') return 'outbound'
|
||||||
|
if (item.type === 'donation') return 'donation'
|
||||||
|
if (item.type === 'other') return 'other'
|
||||||
|
return 'unsorted'
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBoardKey = ref(BOARDS[0].key)
|
||||||
|
const activeStage = ref(BOARDS[0].stages[0])
|
||||||
|
|
||||||
|
const activeBoard = computed(() => BOARDS.find((b) => b.key === activeBoardKey.value))
|
||||||
|
|
||||||
|
watch(activeBoardKey, (key) => {
|
||||||
|
activeStage.value = BOARDS.find((b) => b.key === key).stages[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const bucketedItems = computed(() => {
|
||||||
|
const grouped = {}
|
||||||
|
for (const board of BOARDS) grouped[board.key] = []
|
||||||
|
for (const item of props.items) {
|
||||||
|
grouped[bucketFor(item)].push(item)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
|
function itemsForStage(boardKey, stage) {
|
||||||
|
return bucketedItems.value[boardKey].filter((i) => i.stage === stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardCount(boardKey) {
|
||||||
|
return bucketedItems.value[boardKey].length
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBoard(key) {
|
||||||
|
activeBoardKey.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStage(stage) {
|
||||||
|
activeStage.value = stage
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(stage) {
|
||||||
|
return STAGE_LABELS[stage] || stage
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="kanban-board">
|
||||||
|
<div class="board-tabs" role="tablist" aria-label="Item category">
|
||||||
|
<button
|
||||||
|
v-for="board in BOARDS"
|
||||||
|
:key="board.key"
|
||||||
|
role="tab"
|
||||||
|
:id="`board-tab-${board.key}`"
|
||||||
|
:aria-selected="board.key === activeBoardKey"
|
||||||
|
:aria-controls="`board-panel-${board.key}`"
|
||||||
|
:tabindex="board.key === activeBoardKey ? 0 : -1"
|
||||||
|
type="button"
|
||||||
|
class="board-tab"
|
||||||
|
:class="{ active: board.key === activeBoardKey }"
|
||||||
|
@click="selectBoard(board.key)"
|
||||||
|
>
|
||||||
|
{{ board.label }} ({{ boardCount(board.key) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-breadcrumb" role="tabpanel" :id="`board-panel-${activeBoardKey}`">
|
||||||
|
{{ activeBoard.label }} › {{ labelFor(activeStage) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-stage-strip">
|
||||||
|
<button
|
||||||
|
v-for="stage in activeBoard.stages"
|
||||||
|
:key="stage"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="stage === activeStage"
|
||||||
|
class="stage-strip-button"
|
||||||
|
:class="{ active: stage === activeStage }"
|
||||||
|
@click="selectStage(stage)"
|
||||||
|
>
|
||||||
|
{{ labelFor(stage) }} ({{ itemsForStage(activeBoardKey, stage).length }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div
|
||||||
|
v-for="stage in activeBoard.stages"
|
||||||
|
:key="stage"
|
||||||
|
class="column"
|
||||||
|
:class="{ 'is-active-mobile': stage === activeStage }"
|
||||||
|
>
|
||||||
|
<h3>{{ labelFor(stage) }} ({{ itemsForStage(activeBoardKey, stage).length }})</h3>
|
||||||
|
<button
|
||||||
|
v-for="item in itemsForStage(activeBoardKey, stage)"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="card"
|
||||||
|
@click="emit('select-item', item.id)"
|
||||||
|
>
|
||||||
|
<ModalityBadge :modality="item.modality" />
|
||||||
|
<p class="card-sender">{{ item.sender_id || 'not set' }}</p>
|
||||||
|
<p class="card-snippet">{{ item.raw_content.slice(0, 80) }}</p>
|
||||||
|
<p v-if="item.follow_up_date" class="card-followup">Follow up: {{ item.follow_up_date }}</p>
|
||||||
|
</button>
|
||||||
|
<p v-if="itemsForStage(activeBoardKey, stage).length === 0" class="column-empty">Nothing here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kanban-board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-tab {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-tab.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-breadcrumb {
|
||||||
|
display: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stage-strip {
|
||||||
|
display: none;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-strip-button {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-strip-button.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-height: 44px;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sender {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-snippet {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-followup {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobile-breadcrumb {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stage-strip {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.is-active-mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
frontend/tests/KanbanBoard.spec.js
Normal file
87
frontend/tests/KanbanBoard.spec.js
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue