Concetti

KanbanDataSource (Strategy)

Interfaccia Strategy per disaccoppiare la libreria dal backend. PostgRESTDataSource di default; implementabile per REST custom, GraphQL, mock.

Panoramica

KanbanDataSource è l'interfaccia Strategy che disaccoppia composables e componenti dal backend effettivo. Tutti i composable (useBoardMetadata, useBoardElementi, useBoardService) delegano l'I/O a un'implementazione di questa interfaccia.

useBoardService / *Connected components
         │
         ▼
   KanbanDataSource ◄──────── interfaccia Strategy
         ▲
         │
   ┌─────┴───────────────┬──────────────┐
   │                     │              │
PostgRESTDataSource    GraphQL DS     mock DS
(default)              (custom)       (test)
v2 architettura: prima della v2 i composables avevano accoppiamento hard-coded a PostgREST. La Strategy permette di riusare tutti i componenti UI con qualsiasi backend, basta implementare l'interfaccia.

Interfaccia

interface KanbanDataSource<
  TBoard = unknown,
  TColumn = unknown,
  TItem = unknown,
  TPriority = unknown,
  TTag = unknown,
  TField = unknown,
> {
  /** Carica metadata della board (per ID numerico o codice). null = non trovata. */
  loadBoard(boardIdOrCode: BoardId): Promise<TBoard | null>

  /** Carica colonne/stati della board, già ordinate. */
  loadColumns(boardId: BoardId): Promise<TColumn[]>

  /** Carica tutte le schede attive (filtro soft-delete a cura dell'implementazione). */
  loadItems(boardId: BoardId): Promise<TItem[]>

  /** Carica una singola scheda. undefined = non trovata. */
  loadItem(itemId: ItemId): Promise<TItem | undefined>

  /** Crea una nuova scheda nella board indicata. */
  createItem(boardId: BoardId, data: Partial<TItem>): Promise<TItem | null>

  /** Aggiorna una scheda esistente. */
  updateItem(itemId: ItemId, data: Partial<TItem>): Promise<TItem | null>

  /** Elimina (soft o hard, a discrezione del backend) una scheda. */
  deleteItem(itemId: ItemId): Promise<boolean>

  /** Sposta una scheda in una colonna diversa con un nuovo ordine. */
  moveItem(itemId: ItemId, targetCol: KanbanColumnId, newOrder: number): Promise<MoveItemResult>

  // ── Opzionali ────────────────────────────────────────────────────────────
  loadPriorities?(boardId: BoardId): Promise<TPriority[]>
  loadTags?(boardId: BoardId): Promise<TTag[]>
  loadFields?(boardId: BoardId): Promise<TField[]>
  updateItemTags?(itemId: ItemId, tagIds: TagId[]): Promise<boolean>
}

interface MoveItemResult {
  success: boolean
  /** Se valorizzato, indica colonna finale: la scheda è stata chiusa. */
  closedAt?: string | null
  /** Notifiche accodate come effetto del move (es. email/SMS). */
  notificationsQueued?: number
  [key: string]: unknown
}

I metodi opzionali (loadPriorities, loadTags, loadFields, updateItemTags) permettono a backend minimali di non implementare funzionalità non utilizzate. I composable verificano if (dataSource.loadTags) prima di chiamarli.

PostgRESTDataSource (default)

L'implementazione di default. Preserva il comportamento storico dello schema fasi. Tutte le scelte sono override-abili via PostgRESTDataSourceOptions.

import { PostgRESTDataSource, usePostgRESTClient } from '@pzeta/vue-kanban'

const dataSource = new PostgRESTDataSource({
  client: usePostgRESTClient(),
  schema: 'fasi',                    // default
  endpoints: {
    board: '/board',
    columns: '/fase',
    items: '/scheda',
    itemsView: '/v_schede_kanban',   // null per usare items direttamente
    priorities: '/priorita',
    tags: '/tag',
    fields: '/boardcampo',
    itemTags: '/schedatag',
    moveRpc: '/rpc/sposta_scheda_fase', // null per PATCH diretto su items
  },
  primaryKeys: {
    board: 'idboard',
    column: 'idfase',
    item: 'idscheda',
    priority: 'idpriorita',
    tag: 'idtag',
    field: 'idboardcampo',
    itemTag: 'idschedatag',
  },
  boardCodeField: 'codboard',         // campo per lookup string ID
  getCurrentUserId: () => 0,          // provider userId per RPC
  softDeleteField: 'dataeliminazione', // null per disabilitare
})

Override per backend con schema diverso

const dataSource = new PostgRESTDataSource({
  client: usePostgRESTClient(),
  schema: 'public',
  endpoints: {
    board: '/projects',
    columns: '/project_columns',
    items: '/project_cards',
    itemsView: null,                    // niente vista, usa /project_cards diretto
    moveRpc: null,                      // niente RPC, fa PATCH diretto
    priorities: '/priorities',
    tags: '/labels',
    fields: '/custom_fields',
    itemTags: '/card_labels',
  },
  primaryKeys: {
    board: 'id', column: 'id', item: 'id',
    priority: 'id', tag: 'id', field: 'id', itemTag: 'id',
  },
  boardCodeField: 'slug',
  softDeleteField: null,
})

Implementazione custom

Per backend non-PostgREST (REST custom, GraphQL, tRPC, mock), implementa direttamente l'interfaccia:

import type { KanbanDataSource, MoveItemResult } from '@pzeta/vue-kanban'

class MyApiDataSource implements KanbanDataSource<MyBoard, MyColumn, MyCard> {
  async loadBoard(id: string | number) {
    const res = await fetch(`/api/boards/${id}`)
    if (!res.ok) return null
    return res.json()
  }

  async loadColumns(boardId: string | number) {
    return fetch(`/api/boards/${boardId}/columns`).then(r => r.json())
  }

  async loadItems(boardId: string | number) {
    return fetch(`/api/boards/${boardId}/cards`).then(r => r.json())
  }

  async loadItem(itemId: string | number) {
    const res = await fetch(`/api/cards/${itemId}`)
    return res.ok ? res.json() : undefined
  }

  async createItem(boardId: string | number, data: Partial<MyCard>) {
    const res = await fetch(`/api/boards/${boardId}/cards`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })
    return res.json()
  }

  async updateItem(itemId: string | number, data: Partial<MyCard>) {
    const res = await fetch(`/api/cards/${itemId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })
    return res.json()
  }

  async deleteItem(itemId: string | number) {
    const res = await fetch(`/api/cards/${itemId}`, { method: 'DELETE' })
    return res.ok
  }

  async moveItem(itemId, targetCol, newOrder): Promise<MoveItemResult> {
    const res = await fetch(`/api/cards/${itemId}/move`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ columnId: targetCol, order: newOrder }),
    })
    if (!res.ok) return { success: false }
    return { success: true, ...(await res.json()) }
  }

  // Opzionali: implementare solo se serve
  async loadTags(boardId) { /* ... */ }
  async updateItemTags(itemId, tagIds) { /* ... */ }
}

const myDS = new MyApiDataSource()

Iniezione

Via composable

import { useBoardService } from '@pzeta/vue-kanban'

const service = useBoardService('PROJECT-X', { dataSource: myDS })

Via componente smart (EnhancedKanbanBoard)

EnhancedKanbanBoard istanzia useBoardService internamente. Per iniettare un datasource custom, wrappa il composable in un componente padre e passa i dati a KanbanBoard direttamente, oppure usa il pattern di slot scope.

In alternativa, registra un PostgRESTDataSource con endpoint custom in modo da preservare il flusso del componente smart.

Mock per test

import { describe, it, expect, vi } from 'vitest'
import { useBoardService, type KanbanDataSource } from '@pzeta/vue-kanban'

const mockDataSource: KanbanDataSource = {
  loadBoard: vi.fn().mockResolvedValue({ idboard: 1, codboard: 'TEST' }),
  loadColumns: vi.fn().mockResolvedValue([]),
  loadItems: vi.fn().mockResolvedValue([]),
  loadItem: vi.fn(),
  createItem: vi.fn(),
  updateItem: vi.fn(),
  deleteItem: vi.fn(),
  moveItem: vi.fn().mockResolvedValue({ success: true }),
}

it('carica i dati dal datasource', async () => {
  const service = useBoardService('TEST', { dataSource: mockDataSource })
  await service.loadBoardData()
  expect(mockDataSource.loadBoard).toHaveBeenCalledWith('TEST')
})

TypeScript

Tutti i tipi rilevanti sono esportati dal package root:

import type {
  KanbanDataSource,
  MoveItemResult,
  BoardId,
  TagId,
  FieldId,
  ItemId,
  KanbanColumnId,
} from '@pzeta/vue-kanban'

import {
  PostgRESTDataSource,
  type PostgRESTDataSourceOptions,
  type PostgRESTEndpoints,
  type PostgRESTPrimaryKeys,
} from '@pzeta/vue-kanban'