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:
|
if modality is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
attachment_urls = [attachment.url for attachment in message.attachments]
|
||||||
|
|
||||||
raw_content = message.content
|
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)"
|
display_content = message.content or "(attachment only - see original message)"
|
||||||
snippet = display_content if len(display_content) <= 120 else display_content[:117] + "..."
|
snippet = display_content if len(display_content) <= 120 else display_content[:117] + "..."
|
||||||
|
|
||||||
|
|
@ -33,6 +39,7 @@ class ManualShareCog(commands.Cog):
|
||||||
raw_content=raw_content,
|
raw_content=raw_content,
|
||||||
captured_at=message.created_at,
|
captured_at=message.created_at,
|
||||||
direction=direction_for_modality(modality),
|
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)
|
inbox = self.bot.get_channel(self.config.inbox_channel_id)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class ThreadIngestCog(commands.Cog):
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed = parse_ping_embed(parent_message.embeds[0])
|
parsed = parse_ping_embed(parent_message.embeds[0])
|
||||||
await self.backend_client.create_item(
|
item = await self.backend_client.create_item(
|
||||||
modality=parsed["modality"],
|
modality=parsed["modality"],
|
||||||
raw_content=parsed["raw_content"],
|
raw_content=parsed["raw_content"],
|
||||||
captured_at=parsed["captured_at"],
|
captured_at=parsed["captured_at"],
|
||||||
|
|
@ -33,8 +33,9 @@ class ThreadIngestCog(commands.Cog):
|
||||||
sender_id=parsed["sender_id"],
|
sender_id=parsed["sender_id"],
|
||||||
direction=parsed["direction"],
|
direction=parsed["direction"],
|
||||||
)
|
)
|
||||||
|
item_url = f"{self.config.web_base_url}/?item={item['id']}"
|
||||||
await thread.send(
|
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
|
inbox_channel_id: int
|
||||||
triage_role_id: int
|
triage_role_id: int
|
||||||
backend_base_url: str
|
backend_base_url: str
|
||||||
|
web_base_url: str
|
||||||
google_voice_imap_host: str
|
google_voice_imap_host: str
|
||||||
google_voice_imap_user: str
|
google_voice_imap_user: str
|
||||||
google_voice_imap_password: str
|
google_voice_imap_password: str
|
||||||
|
|
@ -33,6 +34,7 @@ def load_config() -> Config:
|
||||||
inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")),
|
inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")),
|
||||||
triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")),
|
triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")),
|
||||||
backend_base_url=env("CHORUS_BACKEND_BASE_URL"),
|
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_host=env("CHORUS_GVOICE_IMAP_HOST"),
|
||||||
google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"),
|
google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"),
|
||||||
google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"),
|
google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"),
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def _safe_field_value(value: str) -> str:
|
||||||
|
|
||||||
def build_ping_embed(
|
def build_ping_embed(
|
||||||
*, modality: str, sender_id: str | None, snippet: str, raw_content: str,
|
*, 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:
|
) -> discord.Embed:
|
||||||
label = MODALITY_LABELS.get(modality, modality)
|
label = MODALITY_LABELS.get(modality, modality)
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
|
|
@ -52,6 +52,11 @@ def build_ping_embed(
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="raw_content", value=_safe_field_value(raw_content), inline=False,
|
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
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,20 @@ class _FakeConfig:
|
||||||
triage_role_id = 222
|
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 = MagicMock(spec=discord.Message)
|
||||||
message.author.bot = author_bot
|
message.author.bot = author_bot
|
||||||
message.channel.id = channel_id
|
message.channel.id = channel_id
|
||||||
message.channel.name = channel_name
|
message.channel.name = channel_name
|
||||||
message.content = content
|
message.content = content
|
||||||
|
message.attachments = attachments or []
|
||||||
message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0)
|
message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0)
|
||||||
return message
|
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"]
|
sent_embed = inbox.send.call_args.kwargs["embed"]
|
||||||
parsed = parse_ping_embed(sent_embed)
|
parsed = parse_ping_embed(sent_embed)
|
||||||
assert parsed["direction"] == "inbound"
|
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)
|
parsed = ping.parse_ping_embed(embed)
|
||||||
assert parsed["direction"] == "inbound"
|
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:
|
class _FakeConfig:
|
||||||
inbox_channel_id = 111
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_thread_create_passes_direction_to_backend_client():
|
async def test_thread_create_passes_direction_to_backend_client():
|
||||||
bot = MagicMock()
|
bot = MagicMock()
|
||||||
backend_client = AsyncMock()
|
backend_client = AsyncMock()
|
||||||
|
backend_client.create_item.return_value = {"id": 42}
|
||||||
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
|
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
|
||||||
|
|
||||||
embed = build_ping_embed(
|
embed = build_ping_embed(
|
||||||
|
|
@ -22,16 +36,32 @@ async def test_thread_create_passes_direction_to_backend_client():
|
||||||
tzinfo=__import__("datetime").timezone.utc),
|
tzinfo=__import__("datetime").timezone.utc),
|
||||||
direction="outbound",
|
direction="outbound",
|
||||||
)
|
)
|
||||||
parent_message = MagicMock()
|
thread = _make_thread(embed)
|
||||||
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()
|
|
||||||
|
|
||||||
await cog.on_thread_create(thread)
|
await cog.on_thread_create(thread)
|
||||||
|
|
||||||
backend_client.create_item.assert_awaited_once()
|
backend_client.create_item.assert_awaited_once()
|
||||||
assert backend_client.create_item.call_args.kwargs["direction"] == "outbound"
|
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
|
- chorus_data:/data
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- caddy-internal
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
build: ./bot
|
build: ./bot
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -20,7 +25,16 @@ services:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "80"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- caddy-internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
chorus_data:
|
chorus_data:
|
||||||
chorus_bot_state:
|
chorus_bot_state:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
caddy-internal:
|
||||||
|
name: caddy-proxy_caddy-internal
|
||||||
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import TriageList from './components/TriageList.vue'
|
import TriageList from './components/TriageList.vue'
|
||||||
|
import KanbanBoard from './components/KanbanBoard.vue'
|
||||||
import ItemModal from './components/ItemModal.vue'
|
import ItemModal from './components/ItemModal.vue'
|
||||||
import { fetchItems, fetchItem } from './api.js'
|
import { fetchItems, fetchItem } from './api.js'
|
||||||
|
|
||||||
|
|
@ -9,6 +10,14 @@ const selectedItem = ref(null)
|
||||||
const includeDone = ref(false)
|
const includeDone = ref(false)
|
||||||
const errorMessage = ref('')
|
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() {
|
async function loadItems() {
|
||||||
try {
|
try {
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
@ -36,17 +45,44 @@ async function onSaved() {
|
||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadItems)
|
onMounted(async () => {
|
||||||
|
await loadItems()
|
||||||
|
const itemId = new URLSearchParams(window.location.search).get('item')
|
||||||
|
if (itemId) {
|
||||||
|
await openItem(itemId)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Chorus</h1>
|
<h1>Chorus</h1>
|
||||||
|
|
||||||
<label class="include-done-toggle">
|
<div class="view-controls">
|
||||||
<input type="checkbox" v-model="includeDone" @change="loadItems" />
|
<label class="include-done-toggle">
|
||||||
Show completed
|
<input type="checkbox" v-model="includeDone" @change="loadItems" />
|
||||||
</label>
|
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>
|
<p v-if="errorMessage" class="status-message">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
|
@ -54,22 +90,52 @@ onMounted(loadItems)
|
||||||
Nothing to triage right now.
|
Nothing to triage right now.
|
||||||
</p>
|
</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" />
|
<ItemModal :item="selectedItem" @close="closeModal" @saved="onSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.include-done-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
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 {
|
.status-message {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { updateItem } from '../api.js'
|
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 props = defineProps({ item: { type: Object, default: null } })
|
||||||
const emit = defineEmits(['close', 'saved'])
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
@ -11,13 +12,6 @@ const stage = ref('')
|
||||||
const notes = ref('')
|
const notes = ref('')
|
||||||
const followUpDate = 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) => {
|
watch(() => props.item, (item) => {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
senderId.value = item.sender_id || ''
|
senderId.value = item.sender_id || ''
|
||||||
|
|
@ -31,7 +25,7 @@ function stagesForType() {
|
||||||
if (props.item?.direction === 'outbound') return OUTBOUND_STAGES
|
if (props.item?.direction === 'outbound') return OUTBOUND_STAGES
|
||||||
if (type.value === 'donation') return DONATION_STAGES
|
if (type.value === 'donation') return DONATION_STAGES
|
||||||
if (type.value === 'other') return OTHER_STAGES
|
if (type.value === 'other') return OTHER_STAGES
|
||||||
return ['new']
|
return UNSORTED_STAGES
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(type, () => {
|
watch(type, () => {
|
||||||
|
|
@ -71,7 +65,7 @@ async function save() {
|
||||||
|
|
||||||
<label>Stage
|
<label>Stage
|
||||||
<select v-model="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>
|
</select>
|
||||||
</label>
|
</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-surface: #f4f4f6;
|
||||||
--color-text: #1a1a1e;
|
--color-text: #1a1a1e;
|
||||||
--color-text-muted: #5b5b63;
|
--color-text-muted: #5b5b63;
|
||||||
--color-border: #d8d8dd;
|
--color-border: #8c8c91;
|
||||||
--color-accent: #4a6fa5;
|
--color-accent: #4a6fa5;
|
||||||
|
|
||||||
--modality-bh_email: #4a6fa5;
|
--modality-bh_email: #4a6fa5;
|
||||||
--modality-personal_email: #6f9c8f;
|
--modality-personal_email: #6f9c8f;
|
||||||
--modality-bh_text: #a5764a;
|
--modality-bh_text: #8c643f;
|
||||||
--modality-personal_text: #a58a4a;
|
--modality-personal_text: #a58a4a;
|
||||||
--modality-voice: #8a6fa5;
|
--modality-voice: #8a6fa5;
|
||||||
--modality-fb_messenger: #4a6fa5;
|
--modality-fb_messenger: #3b5998;
|
||||||
--modality-nd_messenger: #6f8fa5;
|
--modality-nd_messenger: #597186;
|
||||||
|
|
||||||
--spacing-sm: 0.5rem;
|
--spacing-sm: 0.5rem;
|
||||||
--spacing-md: 1rem;
|
--spacing-md: 1rem;
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
--color-surface: #202024;
|
--color-surface: #202024;
|
||||||
--color-text: #e8e8ea;
|
--color-text: #e8e8ea;
|
||||||
--color-text-muted: #a0a0a8;
|
--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