Make the inbox global instead of per-project
A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.
Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
in place) with a nullable project_id, a new owner_id (cards need direct
ownership once they can have no project), and the CHECK constraint. Cards
that had no status (the old per-project inbox) move to the new global inbox.
status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
method that dealt with a project's columns is generalised to also cover the
inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
since a card may have no project to nest them under --
GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
project_id + status_id, both null for the inbox). New GET/POST
/api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
out of a project, is exclusively PUT /api/cards/order now. A card created
directly in a project (POST /api/projects/{id}/cards) lands in its first
status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
rewritten with full inbox/cross-project coverage. 57 tests pass.
Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
in the same "kanban" drag group as every project's kanban columns, so a
card drags straight from the sidebar into whichever project is open, or
back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
a <draggable> at all, so there was nowhere to drop a card back into an
empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
count); its per-project "New" section is gone, since a project card can no
longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.
Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,16 @@ import draggable from 'vuedraggable'
|
||||
import CardRow from '../components/CardRow.vue'
|
||||
import KanbanCard from '../components/KanbanCard.vue'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { reorderColumn } from '../lib/cardOrder'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import { useInboxStore } from '../stores/inbox'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import type { Card, CardStatus, Project } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cards = useCardsStore()
|
||||
const inbox = useInboxStore()
|
||||
const projects = useProjectsStore()
|
||||
|
||||
const projectId = Number(route.params.id)
|
||||
@@ -38,9 +41,6 @@ const cancelButton = ref<HTMLButtonElement>()
|
||||
const newText = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const newInboxText = ref('')
|
||||
const addingToInbox = ref(false)
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = cards.cards.length
|
||||
if (total === 0) return 'No cards yet.'
|
||||
@@ -53,10 +53,12 @@ const sortedCards = computed(() =>
|
||||
)
|
||||
|
||||
// --- Kanban board --------------------------------------------------------
|
||||
// One column per project status -- the inbox lives in the sidebar now, not
|
||||
// here, though it's still a valid drag source/target (shared "kanban" group).
|
||||
interface Column {
|
||||
key: string
|
||||
title: string
|
||||
statusId: number | null
|
||||
statusId: number
|
||||
cards: Card[]
|
||||
}
|
||||
type ColumnChange = {
|
||||
@@ -68,13 +70,11 @@ type ColumnChange = {
|
||||
const board = ref<Column[]>([])
|
||||
|
||||
function buildColumns(): Column[] {
|
||||
const defs: Omit<Column, 'cards'>[] = [
|
||||
{ key: 'inbox', title: 'Inbox', statusId: null },
|
||||
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })),
|
||||
]
|
||||
return defs.map((def) => ({
|
||||
...def,
|
||||
cards: cards.cards.filter((card) => card.status_id === def.statusId),
|
||||
return statuses.value.map((s) => ({
|
||||
key: `status-${s.id}`,
|
||||
title: s.name,
|
||||
statusId: s.id,
|
||||
cards: cards.cards.filter((card) => card.status_id === s.id),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -86,12 +86,21 @@ function rebuildBoard() {
|
||||
// (e.g. after a move is persisted, or a failed move is rolled back).
|
||||
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
|
||||
|
||||
function onColumnChange(change: ColumnChange, column: Column) {
|
||||
// `added` (card dragged in from another column) or `moved` (reordered within
|
||||
// this one): persist this column's new id order. The source column, if any,
|
||||
// is re-packed server-side. `removed` needs no action here.
|
||||
if (change.added || change.moved) {
|
||||
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id)))
|
||||
async function onColumnChange(change: ColumnChange, column: Column) {
|
||||
// `added` (card dragged in, from another column here or from the sidebar's
|
||||
// inbox) or `moved` (reordered within this one): persist this column's new
|
||||
// id order. The column it left -- another status, or the inbox -- is
|
||||
// re-packed server-side. `removed` needs no action here.
|
||||
if (!change.added && !change.moved) return
|
||||
|
||||
actionError.value = null
|
||||
try {
|
||||
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
|
||||
} catch (e) {
|
||||
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
|
||||
} finally {
|
||||
// Either side of the drag could have been the inbox, so refresh both.
|
||||
await Promise.all([inbox.load(), cards.load(projectId)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,21 +227,6 @@ async function onCreate() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// New cards always land in the inbox (no status), so this just adds one.
|
||||
async function onCreateInbox() {
|
||||
if (!newInboxText.value.trim()) return
|
||||
addingToInbox.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await cards.add(newInboxText.value)
|
||||
newInboxText.value = ''
|
||||
} catch (e) {
|
||||
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
|
||||
} finally {
|
||||
addingToInbox.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -344,12 +338,7 @@ async function onCreateInbox() {
|
||||
<p v-if="cards.loading && !cards.loaded" class="muted">Loading…</p>
|
||||
|
||||
<div v-else class="kanban">
|
||||
<section
|
||||
v-for="column in board"
|
||||
:key="column.key"
|
||||
class="kanban__col"
|
||||
:class="{ 'kanban__col--inbox': column.statusId === null }"
|
||||
>
|
||||
<section v-for="column in board" :key="column.key" class="kanban__col">
|
||||
<header class="kanban__head">
|
||||
<span class="kanban__title">{{ column.title }}</span>
|
||||
<span class="kanban__count">{{ column.cards.length }}</span>
|
||||
@@ -368,22 +357,6 @@ async function onCreateInbox() {
|
||||
<KanbanCard :card="element" />
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<form
|
||||
v-if="column.statusId === null"
|
||||
class="kanban__new"
|
||||
@submit.prevent="onCreateInbox"
|
||||
>
|
||||
<input
|
||||
v-model="newInboxText"
|
||||
type="text"
|
||||
maxlength="1000"
|
||||
required
|
||||
placeholder="New card"
|
||||
aria-label="New card"
|
||||
/>
|
||||
<button type="submit" :disabled="addingToInbox">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user