Merge pull request 'Add mobile-first kanban board view for Chorus triage' (#3) from feat/chorus-triage-views into main
This commit is contained in:
commit
d23c412cf4
17 changed files with 763 additions and 34 deletions
|
|
@ -22,7 +22,13 @@ class ManualShareCog(commands.Cog):
|
|||
if modality is None:
|
||||
return
|
||||
|
||||
attachment_urls = [attachment.url for attachment in message.attachments]
|
||||
|
||||
raw_content = message.content
|
||||
if attachment_urls:
|
||||
attachments_block = "Attachments:\n" + "\n".join(attachment_urls)
|
||||
raw_content = f"{raw_content}\n\n{attachments_block}" if raw_content else attachments_block
|
||||
|
||||
display_content = message.content or "(attachment only - see original message)"
|
||||
snippet = display_content if len(display_content) <= 120 else display_content[:117] + "..."
|
||||
|
||||
|
|
@ -33,6 +39,7 @@ class ManualShareCog(commands.Cog):
|
|||
raw_content=raw_content,
|
||||
captured_at=message.created_at,
|
||||
direction=direction_for_modality(modality),
|
||||
image_url=attachment_urls[0] if attachment_urls else None,
|
||||
)
|
||||
|
||||
inbox = self.bot.get_channel(self.config.inbox_channel_id)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class ThreadIngestCog(commands.Cog):
|
|||
return
|
||||
|
||||
parsed = parse_ping_embed(parent_message.embeds[0])
|
||||
await self.backend_client.create_item(
|
||||
item = await self.backend_client.create_item(
|
||||
modality=parsed["modality"],
|
||||
raw_content=parsed["raw_content"],
|
||||
captured_at=parsed["captured_at"],
|
||||
|
|
@ -33,8 +33,9 @@ class ThreadIngestCog(commands.Cog):
|
|||
sender_id=parsed["sender_id"],
|
||||
direction=parsed["direction"],
|
||||
)
|
||||
item_url = f"{self.config.web_base_url}/?item={item['id']}"
|
||||
await thread.send(
|
||||
"Logged in Chorus - open the app to fill in details and track this one."
|
||||
f"Logged in Chorus - {item_url} to fill in details and track this one."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class Config:
|
|||
inbox_channel_id: int
|
||||
triage_role_id: int
|
||||
backend_base_url: str
|
||||
web_base_url: str
|
||||
google_voice_imap_host: str
|
||||
google_voice_imap_user: str
|
||||
google_voice_imap_password: str
|
||||
|
|
@ -33,6 +34,7 @@ def load_config() -> Config:
|
|||
inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")),
|
||||
triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")),
|
||||
backend_base_url=env("CHORUS_BACKEND_BASE_URL"),
|
||||
web_base_url=os.environ.get("CHORUS_WEB_BASE_URL", "https://chorus.circuitforge.tech"),
|
||||
google_voice_imap_host=env("CHORUS_GVOICE_IMAP_HOST"),
|
||||
google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"),
|
||||
google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def _safe_field_value(value: str) -> str:
|
|||
|
||||
def build_ping_embed(
|
||||
*, modality: str, sender_id: str | None, snippet: str, raw_content: str,
|
||||
captured_at: datetime, direction: str = "inbound",
|
||||
captured_at: datetime, direction: str = "inbound", image_url: str | None = None,
|
||||
) -> discord.Embed:
|
||||
label = MODALITY_LABELS.get(modality, modality)
|
||||
embed = discord.Embed(
|
||||
|
|
@ -52,6 +52,11 @@ def build_ping_embed(
|
|||
embed.add_field(
|
||||
name="raw_content", value=_safe_field_value(raw_content), inline=False,
|
||||
)
|
||||
# Discord CDN attachment URLs carry a signed expiry (ex/is/hm query params),
|
||||
# typically valid ~24h. Fine for same-day triage; a stale link just stops
|
||||
# rendering the preview without breaking anything else in the embed.
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
return embed
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,20 @@ class _FakeConfig:
|
|||
triage_role_id = 222
|
||||
|
||||
|
||||
def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999):
|
||||
def _make_attachment(url):
|
||||
attachment = MagicMock(spec=discord.Attachment)
|
||||
attachment.url = url
|
||||
return attachment
|
||||
|
||||
|
||||
def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999,
|
||||
attachments=None):
|
||||
message = MagicMock(spec=discord.Message)
|
||||
message.author.bot = author_bot
|
||||
message.channel.id = channel_id
|
||||
message.channel.name = channel_name
|
||||
message.content = content
|
||||
message.attachments = attachments or []
|
||||
message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0)
|
||||
return message
|
||||
|
||||
|
|
@ -49,3 +57,42 @@ async def test_existing_modality_channel_still_forwards_with_inbound_direction()
|
|||
sent_embed = inbox.send.call_args.kwargs["embed"]
|
||||
parsed = parse_ping_embed(sent_embed)
|
||||
assert parsed["direction"] == "inbound"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_attachment_includes_image_and_url_in_raw_content():
|
||||
bot = MagicMock()
|
||||
inbox = AsyncMock()
|
||||
bot.get_channel.return_value = inbox
|
||||
cog = ManualShareCog(bot, _FakeConfig())
|
||||
|
||||
attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png")
|
||||
message = _make_message(
|
||||
channel_name="leads-found", content="Found on Nextdoor", attachments=[attachment],
|
||||
)
|
||||
await cog.on_message(message)
|
||||
|
||||
sent_embed = inbox.send.call_args.kwargs["embed"]
|
||||
assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
|
||||
|
||||
parsed = parse_ping_embed(sent_embed)
|
||||
assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"]
|
||||
assert "Found on Nextdoor" in parsed["raw_content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_only_message_still_includes_image():
|
||||
bot = MagicMock()
|
||||
inbox = AsyncMock()
|
||||
bot.get_channel.return_value = inbox
|
||||
cog = ManualShareCog(bot, _FakeConfig())
|
||||
|
||||
attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png")
|
||||
message = _make_message(channel_name="leads-found", content="", attachments=[attachment])
|
||||
await cog.on_message(message)
|
||||
|
||||
sent_embed = inbox.send.call_args.kwargs["embed"]
|
||||
assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
|
||||
|
||||
parsed = parse_ping_embed(sent_embed)
|
||||
assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"]
|
||||
|
|
|
|||
|
|
@ -124,3 +124,21 @@ def test_parse_ping_embed_defaults_direction_when_field_missing():
|
|||
|
||||
parsed = ping.parse_ping_embed(embed)
|
||||
assert parsed["direction"] == "inbound"
|
||||
|
||||
|
||||
def test_build_ping_embed_sets_image_when_url_given():
|
||||
embed = ping.build_ping_embed(
|
||||
modality="lead_found", sender_id=None, snippet="found post",
|
||||
raw_content="Found on Nextdoor",
|
||||
captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc),
|
||||
image_url="https://cdn.discordapp.com/attachments/1/2/screenshot.png",
|
||||
)
|
||||
assert embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
|
||||
|
||||
|
||||
def test_build_ping_embed_no_image_by_default():
|
||||
embed = ping.build_ping_embed(
|
||||
modality="bh_email", sender_id="jane@example.com", snippet="hi",
|
||||
raw_content="hi", captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert embed.image.url is None
|
||||
|
|
|
|||
|
|
@ -7,12 +7,26 @@ from chorus_bot.ping import build_ping_embed
|
|||
|
||||
class _FakeConfig:
|
||||
inbox_channel_id = 111
|
||||
web_base_url = "https://chorus.circuitforge.tech"
|
||||
|
||||
|
||||
def _make_thread(embed):
|
||||
parent_message = MagicMock()
|
||||
parent_message.embeds = [embed]
|
||||
|
||||
thread = MagicMock(spec=discord.Thread)
|
||||
thread.id = 555
|
||||
thread.parent_id = 111
|
||||
thread.parent.fetch_message = AsyncMock(return_value=parent_message)
|
||||
thread.send = AsyncMock()
|
||||
return thread
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_create_passes_direction_to_backend_client():
|
||||
bot = MagicMock()
|
||||
backend_client = AsyncMock()
|
||||
backend_client.create_item.return_value = {"id": 42}
|
||||
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
|
||||
|
||||
embed = build_ping_embed(
|
||||
|
|
@ -22,16 +36,32 @@ async def test_thread_create_passes_direction_to_backend_client():
|
|||
tzinfo=__import__("datetime").timezone.utc),
|
||||
direction="outbound",
|
||||
)
|
||||
parent_message = MagicMock()
|
||||
parent_message.embeds = [embed]
|
||||
|
||||
thread = MagicMock(spec=discord.Thread)
|
||||
thread.id = 555
|
||||
thread.parent_id = 111
|
||||
thread.parent.fetch_message = AsyncMock(return_value=parent_message)
|
||||
thread.send = AsyncMock()
|
||||
thread = _make_thread(embed)
|
||||
|
||||
await cog.on_thread_create(thread)
|
||||
|
||||
backend_client.create_item.assert_awaited_once()
|
||||
assert backend_client.create_item.call_args.kwargs["direction"] == "outbound"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_create_reply_includes_item_link():
|
||||
bot = MagicMock()
|
||||
backend_client = AsyncMock()
|
||||
backend_client.create_item.return_value = {"id": 42}
|
||||
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
|
||||
|
||||
embed = build_ping_embed(
|
||||
modality="lead_found", sender_id=None, snippet="found post",
|
||||
raw_content="Someone posting about book donation",
|
||||
captured_at=__import__("datetime").datetime(2026, 7, 13, 7, 0, 0,
|
||||
tzinfo=__import__("datetime").timezone.utc),
|
||||
direction="outbound",
|
||||
)
|
||||
thread = _make_thread(embed)
|
||||
|
||||
await cog.on_thread_create(thread)
|
||||
|
||||
thread.send.assert_awaited_once()
|
||||
reply_text = thread.send.call_args.args[0]
|
||||
assert "https://chorus.circuitforge.tech/?item=42" in reply_text
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ services:
|
|||
- chorus_data:/data
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
- default
|
||||
- caddy-internal
|
||||
|
||||
bot:
|
||||
build: ./bot
|
||||
env_file: .env
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
|
|
@ -20,7 +25,16 @@ services:
|
|||
build: ./frontend
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- default
|
||||
- caddy-internal
|
||||
|
||||
volumes:
|
||||
chorus_data:
|
||||
chorus_bot_state:
|
||||
|
||||
networks:
|
||||
default:
|
||||
caddy-internal:
|
||||
name: caddy-proxy_caddy-internal
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import TriageList from './components/TriageList.vue'
|
||||
import KanbanBoard from './components/KanbanBoard.vue'
|
||||
import ItemModal from './components/ItemModal.vue'
|
||||
import { fetchItems, fetchItem } from './api.js'
|
||||
|
||||
|
|
@ -9,6 +10,14 @@ const selectedItem = ref(null)
|
|||
const includeDone = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const VIEW_MODE_KEY = 'chorus_view_mode'
|
||||
const storedViewMode = localStorage.getItem(VIEW_MODE_KEY)
|
||||
const viewMode = ref(storedViewMode === 'board' ? 'board' : 'list')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
localStorage.setItem(VIEW_MODE_KEY, mode)
|
||||
})
|
||||
|
||||
async function loadItems() {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
|
|
@ -36,17 +45,44 @@ async function onSaved() {
|
|||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(loadItems)
|
||||
onMounted(async () => {
|
||||
await loadItems()
|
||||
const itemId = new URLSearchParams(window.location.search).get('item')
|
||||
if (itemId) {
|
||||
await openItem(itemId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Chorus</h1>
|
||||
|
||||
<label class="include-done-toggle">
|
||||
<input type="checkbox" v-model="includeDone" @change="loadItems" />
|
||||
Show completed
|
||||
</label>
|
||||
<div class="view-controls">
|
||||
<label class="include-done-toggle">
|
||||
<input type="checkbox" v-model="includeDone" @change="loadItems" />
|
||||
Show completed
|
||||
</label>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:class="{ active: viewMode === 'list' }"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="viewMode === 'board'"
|
||||
:class="{ active: viewMode === 'board' }"
|
||||
@click="viewMode = 'board'"
|
||||
>
|
||||
Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="status-message">{{ errorMessage }}</p>
|
||||
|
||||
|
|
@ -54,22 +90,52 @@ onMounted(loadItems)
|
|||
Nothing to triage right now.
|
||||
</p>
|
||||
|
||||
<TriageList v-else :items="items" @select-item="openItem" />
|
||||
<TriageList v-else-if="viewMode === 'list'" :items="items" @select-item="openItem" />
|
||||
<KanbanBoard v-else :items="items" @select-item="openItem" />
|
||||
|
||||
<ItemModal :item="selectedItem" @close="closeModal" @saved="onSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.include-done-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
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;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-md);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { updateItem } from '../api.js'
|
||||
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
|
||||
|
||||
const props = defineProps({ item: { type: Object, default: null } })
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
|
@ -11,13 +12,6 @@ const stage = ref('')
|
|||
const notes = ref('')
|
||||
const followUpDate = ref('')
|
||||
|
||||
const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark']
|
||||
const OTHER_STAGES = ['new', 'done']
|
||||
const OUTBOUND_STAGES = [
|
||||
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
|
||||
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
|
||||
]
|
||||
|
||||
watch(() => props.item, (item) => {
|
||||
if (!item) return
|
||||
senderId.value = item.sender_id || ''
|
||||
|
|
@ -31,7 +25,7 @@ function stagesForType() {
|
|||
if (props.item?.direction === 'outbound') return OUTBOUND_STAGES
|
||||
if (type.value === 'donation') return DONATION_STAGES
|
||||
if (type.value === 'other') return OTHER_STAGES
|
||||
return ['new']
|
||||
return UNSORTED_STAGES
|
||||
}
|
||||
|
||||
watch(type, () => {
|
||||
|
|
@ -71,7 +65,7 @@ async function save() {
|
|||
|
||||
<label>Stage
|
||||
<select v-model="stage">
|
||||
<option v-for="s in stagesForType()" :key="s" :value="s">{{ s }}</option>
|
||||
<option v-for="s in stagesForType()" :key="s" :value="s">{{ STAGE_LABELS[s] || s }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
304
frontend/src/components/KanbanBoard.vue
Normal file
304
frontend/src/components/KanbanBoard.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, nextTick } 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
|
||||
}
|
||||
|
||||
const tabRefs = ref([])
|
||||
|
||||
function setTabRef(el, index) {
|
||||
tabRefs.value[index] = el
|
||||
}
|
||||
|
||||
async function handleTabKeydown(event, index) {
|
||||
let nextIndex = null
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = (index + 1) % BOARDS.length
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (index - 1 + BOARDS.length) % BOARDS.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = BOARDS.length - 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
selectBoard(BOARDS[nextIndex].key)
|
||||
await nextTick()
|
||||
tabRefs.value[nextIndex]?.focus()
|
||||
}
|
||||
|
||||
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, index) in BOARDS"
|
||||
:key="board.key"
|
||||
:ref="el => setTabRef(el, index)"
|
||||
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)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>
|
||||
{{ board.label }} ({{ boardCount(board.key) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-breadcrumb">
|
||||
{{ 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
|
||||
v-for="board in BOARDS"
|
||||
:key="board.key"
|
||||
v-show="board.key === activeBoardKey"
|
||||
role="tabpanel"
|
||||
:id="`board-panel-${board.key}`"
|
||||
:aria-labelledby="`board-tab-${board.key}`"
|
||||
class="columns"
|
||||
>
|
||||
<div
|
||||
v-for="stage in board.stages"
|
||||
:key="stage"
|
||||
class="column"
|
||||
:class="{ 'is-active-mobile': stage === activeStage && board.key === activeBoardKey }"
|
||||
>
|
||||
<h3>{{ labelFor(stage) }} ({{ itemsForStage(board.key, stage).length }})</h3>
|
||||
<button
|
||||
v-for="item in itemsForStage(board.key, 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(board.key, 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>
|
||||
19
frontend/src/stages.js
Normal file
19
frontend/src/stages.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark']
|
||||
export const OTHER_STAGES = ['new', 'done']
|
||||
export const OUTBOUND_STAGES = [
|
||||
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
|
||||
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
|
||||
]
|
||||
export const UNSORTED_STAGES = ['new']
|
||||
|
||||
export const STAGE_LABELS = {
|
||||
new: 'New',
|
||||
replied: 'Replied',
|
||||
pickup_scheduled: 'Pickup Scheduled',
|
||||
picked_up: 'Picked Up',
|
||||
logged_in_bookmark: 'Logged in Bookmark',
|
||||
done: 'Done',
|
||||
reviewed: 'Reviewed',
|
||||
outreach_drafted: 'Outreach Drafted',
|
||||
outreach_sent: 'Outreach Sent',
|
||||
}
|
||||
|
|
@ -3,16 +3,16 @@
|
|||
--color-surface: #f4f4f6;
|
||||
--color-text: #1a1a1e;
|
||||
--color-text-muted: #5b5b63;
|
||||
--color-border: #d8d8dd;
|
||||
--color-border: #8c8c91;
|
||||
--color-accent: #4a6fa5;
|
||||
|
||||
--modality-bh_email: #4a6fa5;
|
||||
--modality-personal_email: #6f9c8f;
|
||||
--modality-bh_text: #a5764a;
|
||||
--modality-bh_text: #8c643f;
|
||||
--modality-personal_text: #a58a4a;
|
||||
--modality-voice: #8a6fa5;
|
||||
--modality-fb_messenger: #4a6fa5;
|
||||
--modality-nd_messenger: #6f8fa5;
|
||||
--modality-fb_messenger: #3b5998;
|
||||
--modality-nd_messenger: #597186;
|
||||
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
--color-surface: #202024;
|
||||
--color-text: #e8e8ea;
|
||||
--color-text-muted: #a0a0a8;
|
||||
--color-border: #33333a;
|
||||
--color-border: #64646e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
54
frontend/tests/App.spec.js
Normal file
54
frontend/tests/App.spec.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import App from '../src/App.vue'
|
||||
|
||||
vi.mock('../src/api.js', () => ({
|
||||
fetchItems: vi.fn(async () => []),
|
||||
fetchItem: vi.fn(async (id) => ({ id })),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('App view mode', () => {
|
||||
it('defaults to list view when nothing is stored', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
|
||||
expect(listButton.classes()).toContain('active')
|
||||
})
|
||||
|
||||
it('honors a stored board view mode on mount', async () => {
|
||||
localStorage.setItem('chorus_view_mode', 'board')
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
|
||||
expect(boardButton.classes()).toContain('active')
|
||||
})
|
||||
|
||||
it('defaults to list view for an invalid stored value', async () => {
|
||||
localStorage.setItem('chorus_view_mode', 'garbage')
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
|
||||
expect(listButton.classes()).toContain('active')
|
||||
})
|
||||
|
||||
it('clicking Board writes the choice to localStorage', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
|
||||
await boardButton.trigger('click')
|
||||
expect(localStorage.getItem('chorus_view_mode')).toBe('board')
|
||||
})
|
||||
|
||||
it('sets aria-pressed correctly on the view toggle buttons', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
|
||||
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
|
||||
expect(listButton.attributes('aria-pressed')).toBe('true')
|
||||
expect(boardButton.attributes('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
107
frontend/tests/KanbanBoard.spec.js
Normal file
107
frontend/tests/KanbanBoard.spec.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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')
|
||||
})
|
||||
|
||||
it('ArrowRight on a board tab moves selection and focus to the next tab', async () => {
|
||||
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
tabs[0].element.focus()
|
||||
await tabs[0].trigger('keydown', { key: 'ArrowRight' })
|
||||
const updatedTabs = wrapper.findAll('[role="tab"]')
|
||||
expect(updatedTabs[1].attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(updatedTabs[1].element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ArrowLeft wraps from the first tab to the last tab', async () => {
|
||||
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
await tabs[0].trigger('keydown', { key: 'ArrowLeft' })
|
||||
const updatedTabs = wrapper.findAll('[role="tab"]')
|
||||
expect(updatedTabs[3].attributes('aria-selected')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
28
frontend/tests/stages.spec.js
Normal file
28
frontend/tests/stages.spec.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS,
|
||||
} from '../src/stages.js'
|
||||
|
||||
describe('stages.js', () => {
|
||||
it('exports the expected stage lists', () => {
|
||||
expect(DONATION_STAGES).toEqual([
|
||||
'new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
|
||||
])
|
||||
expect(OTHER_STAGES).toEqual(['new', 'done'])
|
||||
expect(OUTBOUND_STAGES).toEqual([
|
||||
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
|
||||
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
|
||||
])
|
||||
expect(UNSORTED_STAGES).toEqual(['new'])
|
||||
})
|
||||
|
||||
it('has a human-readable label for every stage across all lists', () => {
|
||||
const allStages = new Set([
|
||||
...DONATION_STAGES, ...OTHER_STAGES, ...OUTBOUND_STAGES, ...UNSORTED_STAGES,
|
||||
])
|
||||
for (const stage of allStages) {
|
||||
expect(STAGE_LABELS[stage]).toBeTruthy()
|
||||
expect(STAGE_LABELS[stage]).not.toBe(stage)
|
||||
}
|
||||
})
|
||||
})
|
||||
33
frontend/tests/theme.spec.js
Normal file
33
frontend/tests/theme.spec.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const themeCss = readFileSync(join(__dirname, '../src/theme.css'), 'utf-8')
|
||||
|
||||
describe('theme.css contrast fixes', () => {
|
||||
it('strengthens --color-border to a WCAG-compliant value in light mode', () => {
|
||||
expect(themeCss).toContain('--color-border: #8c8c91;')
|
||||
})
|
||||
|
||||
it('strengthens --color-border to a WCAG-compliant value in dark mode', () => {
|
||||
expect(themeCss).toContain('--color-border: #64646e;')
|
||||
})
|
||||
|
||||
it('darkens --modality-bh_text for 4.5:1 contrast with white text', () => {
|
||||
expect(themeCss).toContain('--modality-bh_text: #8c643f;')
|
||||
})
|
||||
|
||||
it('darkens --modality-nd_messenger for 4.5:1 contrast with white text', () => {
|
||||
expect(themeCss).toContain('--modality-nd_messenger: #597186;')
|
||||
})
|
||||
|
||||
it('differentiates --modality-fb_messenger from --modality-bh_email', () => {
|
||||
const bhEmailMatch = themeCss.match(/--modality-bh_email: (#[0-9a-f]{6});/)
|
||||
const fbMessengerMatch = themeCss.match(/--modality-fb_messenger: (#[0-9a-f]{6});/)
|
||||
expect(bhEmailMatch).not.toBeNull()
|
||||
expect(fbMessengerMatch).not.toBeNull()
|
||||
expect(bhEmailMatch[1]).not.toBe(fbMessengerMatch[1])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue