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:
@@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import draggable from 'vuedraggable'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { reorderColumn } from '../lib/cardOrder'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import { useInboxStore } from '../stores/inbox'
|
||||
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
|
||||
import type { Card } from '../types'
|
||||
import KanbanCard from './KanbanCard.vue'
|
||||
|
||||
const projects = useProjectsStore()
|
||||
const cards = useCardsStore()
|
||||
const inbox = useInboxStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const title = ref('')
|
||||
const createError = ref<ApiError | null>(null)
|
||||
@@ -15,10 +24,11 @@ const loadError = ref<string | null>(null)
|
||||
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
|
||||
|
||||
onMounted(() => {
|
||||
if (!projects.loaded) void load()
|
||||
if (!projects.loaded) void loadProjects()
|
||||
if (!inbox.loaded) void loadInbox()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
async function loadProjects() {
|
||||
loadError.value = null
|
||||
try {
|
||||
await projects.fetchProjects()
|
||||
@@ -40,6 +50,58 @@ async function onCreate() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox: a global holding area, not tied to any project. Cards drag in
|
||||
// and out of it from a project's kanban board (shared "kanban" group).
|
||||
const newInboxText = ref('')
|
||||
const addingToInbox = ref(false)
|
||||
const inboxError = ref<string | null>(null)
|
||||
|
||||
type ColumnChange = {
|
||||
added?: { element: Card; newIndex: number }
|
||||
moved?: { element: Card; oldIndex: number; newIndex: number }
|
||||
}
|
||||
|
||||
async function loadInbox() {
|
||||
inboxError.value = null
|
||||
try {
|
||||
await inbox.load()
|
||||
} catch (e) {
|
||||
inboxError.value = e instanceof ApiError ? e.message : 'Could not load the inbox.'
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateInboxCard() {
|
||||
if (!newInboxText.value.trim()) return
|
||||
addingToInbox.value = true
|
||||
inboxError.value = null
|
||||
try {
|
||||
await inbox.add(newInboxText.value)
|
||||
newInboxText.value = ''
|
||||
} catch (e) {
|
||||
inboxError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
|
||||
} finally {
|
||||
addingToInbox.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onInboxChange(change: ColumnChange) {
|
||||
if (!change.added && !change.moved) return
|
||||
|
||||
inboxError.value = null
|
||||
try {
|
||||
await reorderColumn(null, null, inbox.cards.map((c) => c.id))
|
||||
} catch (e) {
|
||||
inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
|
||||
} finally {
|
||||
// The card may have come from (or gone to) the project currently open.
|
||||
const openProjectId = route.name === 'project' ? Number(route.params.id) : null
|
||||
await Promise.all([
|
||||
loadInbox(),
|
||||
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
|
||||
])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,5 +167,43 @@ async function onCreate() {
|
||||
</button>
|
||||
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
|
||||
</form>
|
||||
|
||||
<hr class="sidebar__divider" />
|
||||
|
||||
<p class="sidebar__heading">Inbox</p>
|
||||
|
||||
<p v-if="inboxError" class="sidebar__note form-error">{{ inboxError }}</p>
|
||||
<p v-else-if="inbox.loading && !inbox.loaded" class="sidebar__note muted">Loading…</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Always rendered (even empty) so it stays a valid drop target for a
|
||||
card dragged out of a project's kanban board. -->
|
||||
<p v-if="inbox.cards.length === 0" class="sidebar__note muted">Nothing in the inbox.</p>
|
||||
<draggable
|
||||
:list="inbox.cards"
|
||||
:group="{ name: 'kanban' }"
|
||||
item-key="id"
|
||||
class="kanban__cards sidebar__inbox-cards"
|
||||
ghost-class="kanban-card--ghost"
|
||||
:animation="150"
|
||||
@change="onInboxChange"
|
||||
>
|
||||
<template #item="{ element }: { element: Card }">
|
||||
<KanbanCard :card="element" />
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<form class="kanban__new" @submit.prevent="onCreateInboxCard">
|
||||
<input
|
||||
v-model="newInboxText"
|
||||
type="text"
|
||||
maxlength="1000"
|
||||
required
|
||||
placeholder="New card"
|
||||
aria-label="New card"
|
||||
/>
|
||||
<button type="submit" :disabled="addingToInbox">Add</button>
|
||||
</form>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user