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:
@@ -4,11 +4,13 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useCardsStore } from './stores/cards'
|
||||
import { useInboxStore } from './stores/inbox'
|
||||
import { useProjectsStore } from './stores/projects'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const projects = useProjectsStore()
|
||||
const cards = useCardsStore()
|
||||
const inbox = useInboxStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -20,6 +22,7 @@ async function onLogout() {
|
||||
auth.logout()
|
||||
projects.reset()
|
||||
cards.reset()
|
||||
inbox.reset()
|
||||
await router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from './api'
|
||||
import type { Card } from '../types'
|
||||
|
||||
/**
|
||||
* Set the contents and order of one column -- the inbox (both null) or a
|
||||
* project's status (both set). Shared by the sidebar's inbox list and a
|
||||
* project's kanban columns, since either can be a drag source or target for
|
||||
* the other (dragging a card in re-parents it; its old column is re-packed
|
||||
* server-side).
|
||||
*/
|
||||
export async function reorderColumn(
|
||||
projectId: number | null,
|
||||
statusId: number | null,
|
||||
cardIds: number[],
|
||||
): Promise<{ cards: Card[] }> {
|
||||
return apiRequest<{ cards: Card[] }>('/cards/order', {
|
||||
method: 'PUT',
|
||||
auth: true,
|
||||
body: { project_id: projectId, status_id: statusId, card_ids: cardIds },
|
||||
})
|
||||
}
|
||||
+10
-21
@@ -6,8 +6,10 @@ import type { Card } from '../types'
|
||||
type CardPatch = Partial<Pick<Card, 'text' | 'complete'>>
|
||||
|
||||
export const useCardsStore = defineStore('cards', () => {
|
||||
// Every card in the project, grouped by column (inbox first) then position.
|
||||
// Views re-sort as needed (the "all tasks" list is alphabetical).
|
||||
// One project's cards, grouped by status then position. Views re-sort as
|
||||
// needed (the "all tasks" list is alphabetical). Moving a card in or out of
|
||||
// this project (including via the inbox) goes through lib/cardOrder.ts,
|
||||
// not this store -- callers re-load() afterwards.
|
||||
const cards = ref<Card[]>([])
|
||||
const projectId = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -41,10 +43,11 @@ export const useCardsStore = defineStore('cards', () => {
|
||||
}
|
||||
|
||||
async function patch(card: Card, fields: CardPatch): Promise<void> {
|
||||
const { card: updated } = await apiRequest<{ card: Card }>(
|
||||
`/projects/${projectId.value}/cards/${card.id}`,
|
||||
{ method: 'PATCH', auth: true, body: fields },
|
||||
)
|
||||
const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${card.id}`, {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: fields,
|
||||
})
|
||||
const i = cards.value.findIndex((x) => x.id === updated.id)
|
||||
if (i !== -1) cards.value[i] = updated
|
||||
}
|
||||
@@ -53,23 +56,10 @@ export const useCardsStore = defineStore('cards', () => {
|
||||
const setText = (card: Card, text: string) => patch(card, { text })
|
||||
|
||||
async function remove(card: Card): Promise<void> {
|
||||
await apiRequest(`/projects/${projectId.value}/cards/${card.id}`, { method: 'DELETE', auth: true })
|
||||
await apiRequest(`/cards/${card.id}`, { method: 'DELETE', auth: true })
|
||||
cards.value = cards.value.filter((c) => c.id !== card.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents and order of one status column (`null` = inbox). Cards
|
||||
* dragged in from another column are re-parented server-side; the response is
|
||||
* the whole project's cards, which replaces local state.
|
||||
*/
|
||||
async function reorderColumn(statusId: number | null, cardIds: number[]): Promise<void> {
|
||||
const { cards: fresh } = await apiRequest<{ cards: Card[] }>(
|
||||
`/projects/${projectId.value}/cards/order`,
|
||||
{ method: 'PUT', auth: true, body: { status_id: statusId, card_ids: cardIds } },
|
||||
)
|
||||
cards.value = fresh
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
cards.value = []
|
||||
projectId.value = null
|
||||
@@ -87,7 +77,6 @@ export const useCardsStore = defineStore('cards', () => {
|
||||
setComplete,
|
||||
setText,
|
||||
remove,
|
||||
reorderColumn,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { apiRequest } from '../lib/api'
|
||||
import type { Card } from '../types'
|
||||
|
||||
/**
|
||||
* The signed-in user's global inbox: cards with no project. Loaded once and
|
||||
* kept mounted in the sidebar for the whole session, so it stays visible (and
|
||||
* a drag target/source) while navigating between the dashboard and projects.
|
||||
*/
|
||||
export const useInboxStore = defineStore('inbox', () => {
|
||||
const cards = ref<Card[]>([])
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const { cards: fetched } = await apiRequest<{ cards: Card[] }>('/inbox/cards', { auth: true })
|
||||
cards.value = fetched
|
||||
loaded.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function add(text: string): Promise<void> {
|
||||
const { card } = await apiRequest<{ card: Card }>('/inbox/cards', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { text },
|
||||
})
|
||||
// The API appends the card, so the end of the array is its correct place.
|
||||
cards.value.push(card)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
cards.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { cards, loaded, loading, load, add, reset }
|
||||
})
|
||||
+15
-38
@@ -163,6 +163,12 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar__inbox-cards {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.1rem; /* room for the ghost card's outline while dragging */
|
||||
}
|
||||
|
||||
.sidebar__note {
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -219,12 +225,12 @@ h1 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- dashboard: grid of projects with their inbox ------------------- */
|
||||
/* --- dashboard: grid of projects --------------------------------------- */
|
||||
|
||||
.dashboard__grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -237,50 +243,21 @@ h1 {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.project-card__title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.project-card__heading {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-card__cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-card__cards li {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.project-card__empty {
|
||||
margin: 0;
|
||||
.project-card__meta {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -31,9 +31,13 @@ export interface CardStatus {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A card either sits in its owner's global inbox (project_id and status_id
|
||||
* both null) or belongs to one project with a status in it (both set).
|
||||
*/
|
||||
export interface Card {
|
||||
id: number
|
||||
project_id: number
|
||||
project_id: number | null
|
||||
text: string
|
||||
complete: boolean
|
||||
position: number
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import type { Card } from '../types'
|
||||
|
||||
const projects = useProjectsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
/** project id -> its inbox cards (status_id === null) */
|
||||
const inbox = ref<Record<number, Card[]>>({})
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
await projects.fetchProjects()
|
||||
const entries = await Promise.all(
|
||||
projects.projects.map(async (project): Promise<[number, Card[]]> => {
|
||||
const { cards } = await apiRequest<{ cards: Card[] }>(`/projects/${project.id}/cards`, {
|
||||
auth: true,
|
||||
})
|
||||
return [project.id, cards.filter((card) => card.status_id === null)]
|
||||
}),
|
||||
)
|
||||
inbox.value = Object.fromEntries(entries)
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -39,27 +23,24 @@ async function load() {
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<p v-if="loadError" class="form-error">{{ loadError }}</p>
|
||||
<p v-else-if="loading" class="muted">Loading…</p>
|
||||
<p v-else-if="projects.loading && !projects.loaded" class="muted">Loading…</p>
|
||||
<p v-else-if="projects.projects.length === 0" class="muted">
|
||||
No projects yet — create one from the sidebar.
|
||||
No projects yet — create one from the sidebar. Anything uncategorised
|
||||
lives in the inbox, also in the sidebar.
|
||||
</p>
|
||||
|
||||
<div v-else class="dashboard__grid">
|
||||
<article v-for="project in projects.projects" :key="project.id" class="project-card">
|
||||
<RouterLink
|
||||
:to="{ name: 'project', params: { id: project.id } }"
|
||||
class="project-card__title"
|
||||
>
|
||||
{{ project.title }}
|
||||
</RouterLink>
|
||||
|
||||
<p class="project-card__heading">New</p>
|
||||
|
||||
<ul v-if="inbox[project.id]?.length" class="project-card__cards">
|
||||
<li v-for="card in inbox[project.id]" :key="card.id">{{ card.text }}</li>
|
||||
</ul>
|
||||
<p v-else class="project-card__empty muted">Nothing new.</p>
|
||||
</article>
|
||||
<RouterLink
|
||||
v-for="project in projects.projects"
|
||||
:key="project.id"
|
||||
:to="{ name: 'project', params: { id: project.id } }"
|
||||
class="project-card"
|
||||
>
|
||||
<span class="project-card__title">{{ project.title }}</span>
|
||||
<span class="project-card__meta muted">
|
||||
{{ project.card_count }} card{{ project.card_count === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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