Skip to content

Async Approval And Persistence

Configure persistence to save canonical card changes through an asynchronous adapter.

Load the complete canonical dataset and its server versions before enabling persistence. Partial/indexed remote loading cannot be combined with this adapter.

import { KanbanPlugin, type KanbanPersistenceOptions } from '@revolist/kanban';
type Card = { id: string; title: string; status: string; order: number };
const loaded: { cardId: string; card: Card; version: string | number }[] =
await fetch('/api/kanban').then(response => response.json());
const initialVersions = new Map(loaded.map(row => [row.cardId, row.version]));
const persistence: KanbanPersistenceOptions<Card> = {
optimistic: true,
getVersion: (_card, id) => initialVersions.get(String(id)) ?? null,
prepareMove: async ({ cards, target, signal }) => {
const response = await fetch('/api/kanban/approve-move', {
method: 'POST', signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cardIds: cards.map(card => card.id), target }),
});
if (!response.ok) return 'Move approval was denied.';
return true;
},
commit: async ({ signal, ...request }) => {
const response = await fetch('/api/kanban', {
method: 'POST', signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(request),
});
// A network error, timeout or non-2xx response may happen AFTER a server write.
// Throwing retains the operation for an idempotent retry; it does not assume rollback.
if (!response.ok) throw new Error(`Save returned HTTP ${response.status}`);
return response.json();
},
};
grid.plugins = [KanbanPlugin];
grid.source = loaded.map(row => row.card);
grid.kanban = {
columns: [{ prop: 'todo', name: 'To do' }, { prop: 'doing', name: 'Doing' }],
persistence,
};

prepareMove is optional. The runnable reference API described below implements GET/POST /cards; omit that approval hook when running the reference unchanged. A real approval endpoint can refresh permissions, but the final write endpoint must enforce authorization, WIP and transition rules again atomically.

The existing editor and create/update/delete methods capture one canonical save automatically. Do not also save from kanbancardmove, editor callbacks or gridedit: that would duplicate writes. A batch contains all changed canonical cards, including rank-rebalanced neighbors; projected grid rows are never persisted.

const plugins = await grid.getPlugins();
const kanban = plugins.find(plugin => plugin instanceof KanbanPlugin)!;
const committed = await kanban.moveCardsAsync(['a'], { columnProp: 'doing' });

The promise resolves after remote acceptance or a definitive rejection/cancellation. While a network outcome or version conflict is unresolved it remains pending until the user reconciles it. Pointer, keyboard and context-menu moves use this path automatically when persistence is configured. Rules and ranks are recomputed after approval against current cards. With prepareMove configured, synchronous direct moveCards returns false; use moveCardsAsync to await approval. Without the adapter, moveCards retains its boolean contract and fast path.

Every request has an operationId, an action, and changes. Each change includes cardId, detached previousCard/card snapshots, sourceIndex, and expectedVersion. A null card deletes a card; a null expected version means the identity is absent. Versions are opaque strings or numbers, with null reserved for absence.

Return exactly one of these correlated results:

// The batch was durably committed. Include a version for every changed identity.
{ operationId, status: 'accepted', versions: [{ cardId: 'a', version: 42 }] }
// Definitive policy/validation rejection: no part of the batch was written.
{ operationId, status: 'rejected', message: 'Doing is at capacity.' }
// Compare-and-swap failed: no part was written. Include ALL affected identities.
{ operationId, status: 'conflict', message: 'Card changed elsewhere.',
cards: [{ cardId: 'a', card: latestCard, version: 43 }] }

The reference serves all three result envelopes with HTTP 200. If your endpoint uses HTTP 409 for a conflict, parse and return its correlated conflict envelope in commit instead of throwing for that known response. Unstructured HTTP failures still have an unknown outcome.

Cache the receipt by operation ID in the same database transaction as the writes. The same ID/body must return the original receipt even after a response is lost. Use a new ID only for a new logical action; the client does this when rebasing a known conflict. Never report a timeout as a definitive rejection.

The board exposes non-blocking status, Retry save, Cancel (before submission), Use server data, Retry changes, and Dismiss controls. Hosts can also listen to kanbanpersistencechange and query getPersistenceState(). The shared status panel and badges follow Pro theme tokens and keep actions usable in narrow containers.

kanban.retryPersistence(operationId); // Same ID and body after unknown failure
kanban.cancelPersistence(operationId); // Cancels unsent work; submitted work becomes unknown
kanban.resolvePersistenceConflict(operationId, 'server'); // Load authoritative fields, retain later local edits
kanban.resolvePersistenceConflict(operationId, 'retry'); // Rebase this delta on the latest version with a new ID

Writes touching the same identity serialize; unrelated identities can save concurrently. Queued payloads are rebuilt from acknowledged state plus their own field changes. A rejected move therefore cannot reappear inside a later title-save payload. Rollback reprojects only the affected fields and retains later edits and unrelated cards. A conflict pauses overlapping work until an explicit choice. An error after submission keeps optimistic state and ordering until the same operation is retried. Set optimistic: false to leave proposed changes off the board until accepted.

Only accepted writes enter undo history. Undo/redo waits for a server-approved write before shared history changes its stacks; a rejected replay leaves both cards and history intact. Unresolved ordinary writes/approvals block replay. Hosts with their own history controls should also disable those controls while persistence is busy.

Replacing grid.source, replacing the adapter, disabling Kanban or destroying the board invalidates old callbacks. A response cannot overwrite the replacement dataset. Aborting a submitted request does not prove that the server cancelled it: reload authoritative data before starting a new board/session after abandonment.

Keep card identities stable. For server-assigned IDs, use the packaged editor’s existing asynchronous createCardId callback to allocate the ID before creation. Response-time identity remapping is not supported. Extra server-normalized card fields must arrive through a subsequent authoritative reload; receipts advance revisions and do not overwrite cards with arbitrary whole-object server snapshots.

The source checkout contains packages/kanban/server-reference/server.ts:

Terminal window
pnpm exec tsx packages/kanban/server-reference/server.ts

It binds to 127.0.0.1:4042, serves GET/POST /cards, applies atomic version checks, caches operation receipts, and enforces a sample Doing capacity of two. Proxy your frontend’s /api/kanban route to /cards. Its process-local storage is a reference; production storage, durable idempotency, authentication and tenant isolation belong to the host. The adjacent README describes that boundary and server-ID allocation.

The activity popup floats above the board using the browser’s Popover API and a subtle shadow. Use Collapse activity to reduce it to a compact bar with the number of pending messages. Use Expand activity to restore the details and actions. Escape also collapses it. Requests continue while collapsed, and new activity updates the count without expanding the popup.

Use kanban.labels to translate the activity popup, just like other Kanban controls. kanban.locale formats its numeric count. Translate both visible labels and accessible control/count labels:

grid.kanban = {
...grid.kanban,
locale: 'pt-PT',
labels: {
...grid.kanban?.labels,
persistenceTitle: 'Atividade',
persistencePreparing: 'A aguardar aprovação',
persistenceQueued: 'Gravação em espera',
persistenceSaving: 'A guardar alterações',
persistenceError: 'Não foi possível confirmar a gravação',
persistenceConflict: 'Rever alterações',
persistenceCommitted: 'Alterações guardadas',
persistenceRejected: 'Alteração recusada',
persistenceCancelled: 'Pedido cancelado',
persistencePreparingDescription: 'A aguardar aprovação do movimento.',
persistenceQueuedDescription: 'A aguardar a gravação anterior.',
persistenceSavingDescription: 'As suas alterações estão a ser guardadas.',
persistenceConflictDescription: 'Este cartão foi atualizado por outra pessoa.',
persistenceNewerEdits: 'As suas edições mais recentes são preservadas.',
persistenceRetryChanges: 'Tentar novamente',
persistenceUseServer: 'Usar dados remotos',
persistenceRetrySave: 'Repetir gravação',
persistenceCancel: 'Cancelar',
persistenceDismiss: 'Dispensar',
persistenceCollapse: 'Recolher atividade',
persistenceExpand: 'Expandir atividade',
persistenceActivityCount: count => `${count} alterações`,
},
};

The persistence* entries in KanbanLabels also cover internally generated notices, including denied approval, cancellation, changed card existence, invalid receipts, and retry instructions. persistenceInitialReadError(message) and persistenceRetryMessage(message) format diagnostic details.

Messages returned by your persistence adapter remain application-owned. Return localized messages from commit and prepareMove, or provide labels.persistenceMessage(state) to map them to translated UI text. Return undefined to use the translated status description instead. Label changes apply to the mounted board without replacing the persistence adapter.

The popup animates its entrance, exit, and compact/expanded transitions. These short transitions are skipped when the user requests reduced motion. Closing activity never delays or changes the outcome of a save.