Services

Client interfaces (DI)

Contratti IImportClient, IExportClient, ITemplateClient per sostituire il backend senza ereditare da ImportExportApi. Pattern Dependency Injection per testing, mock e SDK custom.

Perché esistono

ImportExportApi è una classe concreta che parla con i microservizi node-excel-import / node-excel-export. Per usare la libreria con un backend diverso (SDK custom, GraphQL, mock di test, contratto wire alternativo) senza ereditare dalla classe, basta implementare le 3 interfacce esposte.

I componenti ImportWizard e ExportDialog accettano la prop api: ImportExportClient (l'unione delle 3 interfacce) — accetta quindi qualsiasi oggetto che soddisfi i contratti.

I 3 contratti

IImportClient

interface IImportClient {
  uploadFile(
    file: File,
    risorsa: string,
    opzioniCsv?: Partial<CsvOpzioni>,
    valoriFissi?: Record<string, unknown>   // dalla 0.2.5
  ): Promise<UploadResult>
  testImport(sessionId: string, mappings: MappingColonna[]): Promise<TestImportResult>
  executeImport(sessionId: string, mappings: MappingColonna[]): Promise<ImportResult>
  getImportStatus(importId: string): Promise<ImportProgresso>
  uploadImages(sessionId: string, files: File[]): Promise<{ caricati: number; errori: string[] }>
  downloadTemplate(risorsa: string, formato: string): Promise<Blob>
  getImportHistory(risorsa?: string): Promise<ImportHistoryItem[]>
  getImportFieldMetadata(risorsa: string): Promise<CampoMetadato[]>
  cancelUpload(): void
}

Operazioni di import: upload, dry-run, esecuzione, polling status, immagini collegate, template d'esempio, storico, metadata campi destinazione.

valoriFissi arriva da ImportWizard.valoriFissi (o da useImport().valoriFissi): vanno applicati a ogni riga importata. ImportExportApi li manda nel campo multipart fixedValues.

IExportClient

interface IExportClient {
  executeExport(request: ExportRequest): Promise<ServerAsyncExportPayload | Blob>
  getExportStatus(exportId: string): Promise<ExportProgresso>
  downloadExport(url: string): Promise<Blob>
  getExportFieldMetadata(risorsa: string): Promise<CampoMetadato[]>
  cancelExport(): void
}

Operazioni di export: esecuzione (sincrona/asincrona), polling, download del file generato, metadata campi esportabili.

request.ordinamento contiene tutti i criteri, in ordine di priorità, ognuno con nulls facoltativo ('first' | 'last', dalla 0.7.0); request.filtri va passato al backend così com'è. Formati: ExportDialog › Filtri e ordinamento.

ITemplateClient

interface ITemplateClient {
  getTemplates(risorsa?: string): Promise<ModelloExport[]>
  createTemplate(
    template: Omit<ModelloExport, 'id' | 'dataCreazione' | 'dataModifica' | 'creatoDa'>
  ): Promise<ModelloExport>
  updateTemplate(id: string, template: Partial<ModelloExport>): Promise<ModelloExport>
  deleteTemplate(id: string): Promise<void>
}

CRUD dei template di export salvati (modelli riusabili con campi/filtri/ordinamento).

ImportExportClient (union)

type ImportExportClient = IImportClient & IExportClient & ITemplateClient

Tipo accettato da ImportWizard.api / ExportDialog.api. Le prop sui componenti smart richiedono l'unione completa: anche se un caso d'uso esercita solo le funzioni di import, l'oggetto deve esporre comunque tutti i 18 metodi (9 di import, 5 di export, 4 di template) per soddisfare il tipo. I composable chiedono solo la parte che usano: useImport(api?: IImportClient), useExport(api?: IExportClient & ITemplateClient), useExportTemplates(api: ITemplateClient).

Errori: un client custom che lancia ApiError (esportato dalla 0.8.0, new ApiError(status, messaggioServizio, message)) ottiene in ExportDialog gli stessi messaggi d'errore di ImportExportApi; un altro Error mostra il proprio message dopo l'apertura («Impossibile salvare il modello. …»). Vedi Gestione errori.
Stub conveniente: per implementazioni parziali (es. solo import in test), puoi castare as ImportExportClient un oggetto che implementa solo i metodi necessari. Vedi sezione Mock per test.

Esempio: mock per test

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ImportWizard, type ImportExportClient } from '@pzeta/vue-importexport'

const mockClient = {
  // Solo i metodi che il test esercita
  uploadFile: vi.fn().mockResolvedValue({
    sessionId: 'sess-1',
    righe: 100,
    colonne: ['nome', 'cognome', 'email'],
  }),
  getImportFieldMetadata: vi.fn().mockResolvedValue([
    { nome: 'nome', etichetta: 'Nome', tipo: 'string', obbligatorio: true },
    { nome: 'cognome', etichetta: 'Cognome', tipo: 'string', obbligatorio: true },
    { nome: 'email', etichetta: 'Email', tipo: 'email', obbligatorio: false },
  ]),
  testImport: vi.fn().mockResolvedValue({ righeValide: 98, righeErrore: 2, errori: [] }),
  executeImport: vi.fn().mockResolvedValue({
    stato: 'completato',
    righeImportate: 98,
    righeScartate: 2,
  }),
  cancelUpload: vi.fn(),
  // I restanti metodi delle 3 interfacce non sono toccati nel test
} as unknown as ImportExportClient

it("esegue l'upload e il test", async () => {
  const wrapper = mount(ImportWizard, {
    props: {
      visible: true,
      baseUrl: 'http://localhost',
      risorsa: 'anagrafica',
      api: mockClient,
    },
  })

  // ... interagisci col wizard
  expect(mockClient.uploadFile).toHaveBeenCalled()
})

Esempio: backend GraphQL

Skeleton per backend GraphQL — i 3 contratti vengono implementati separatamente e composti:

import type {
  IImportClient,
  IExportClient,
  ITemplateClient,
  ImportExportClient,
} from '@pzeta/vue-importexport'
import type { GraphQLClient } from 'graphql-request'

class GraphQLImportClient implements IImportClient {
  constructor(private readonly gql: GraphQLClient) {}

  async uploadFile(file, risorsa, opzioniCsv, valoriFissi) {
    // GraphQL multipart upload spec
    const { upload } = await this.gql.request(UPLOAD_MUTATION, { file, risorsa, opzioniCsv, valoriFissi })
    return upload
  }

  async testImport(sessionId, mappings) {
    const { test } = await this.gql.request(TEST_MUTATION, { sessionId, mappings })
    return test
  }

  async executeImport(sessionId, mappings) {
    const { execute } = await this.gql.request(EXECUTE_MUTATION, { sessionId, mappings })
    return execute
  }

  async getImportStatus(importId) {
    const { status } = await this.gql.request(STATUS_QUERY, { importId })
    return status
  }

  async uploadImages(sessionId, files) { /* ... */ }
  async downloadTemplate(risorsa, formato) {
    // GraphQL non gestisce binari: fallback a endpoint REST/CDN dedicato
    const res = await fetch(`/import/template/${risorsa}?formato=${formato}`)
    return res.blob()
  }
  async getImportHistory(risorsa) { /* ... */ }
  async getImportFieldMetadata(risorsa) { /* ... */ }
  cancelUpload() { /* GraphQL request abort */ }
}

class GraphQLExportClient implements IExportClient { /* ... */ }
class GraphQLTemplateClient implements ITemplateClient { /* ... */ }

// Composizione delle 3 implementazioni
function createGraphQLClient(gql: GraphQLClient): ImportExportClient {
  const importer = new GraphQLImportClient(gql)
  const exporter = new GraphQLExportClient(gql)
  const templates = new GraphQLTemplateClient(gql)

  // I metodi di classe stanno sul prototipo: Object.assign / spread non li copierebbero.
  // Si delega esplicitamente, così `this` resta quello di ogni implementazione.
  return {
    uploadFile: (...a) => importer.uploadFile(...a),
    testImport: (...a) => importer.testImport(...a),
    executeImport: (...a) => importer.executeImport(...a),
    getImportStatus: (...a) => importer.getImportStatus(...a),
    uploadImages: (...a) => importer.uploadImages(...a),
    downloadTemplate: (...a) => importer.downloadTemplate(...a),
    getImportHistory: (...a) => importer.getImportHistory(...a),
    getImportFieldMetadata: (...a) => importer.getImportFieldMetadata(...a),
    cancelUpload: () => importer.cancelUpload(),
    executeExport: (...a) => exporter.executeExport(...a),
    getExportStatus: (...a) => exporter.getExportStatus(...a),
    downloadExport: (...a) => exporter.downloadExport(...a),
    getExportFieldMetadata: (...a) => exporter.getExportFieldMetadata(...a),
    cancelExport: () => exporter.cancelExport(),
    getTemplates: (...a) => templates.getTemplates(...a),
    createTemplate: (...a) => templates.createTemplate(...a),
    updateTemplate: (...a) => templates.updateTemplate(...a),
    deleteTemplate: (...a) => templates.deleteTemplate(...a),
  }
}

const myClient = createGraphQLClient(myGraphQLClient)
<ImportWizard :api="myClient" base-url="" risorsa="anagrafica" />
Download e binari: GraphQL non è il trasporto ideale per byte stream. Anche con un client GraphQL è prassi delegare downloadTemplate, downloadExport e executeExport (per Blob immediati) a endpoint REST/CDN dedicati.

Esempio: estensione di ImportExportApi

Per estendere il client default (es. aggiungere caching, retry, header custom) puoi sottoclassarlo:

import { ImportExportApi, type ImportExportApiOptions } from '@pzeta/vue-importexport'
import type { CampoMetadato } from '@pzeta/vue-importexport'

class CachedImportExportApi extends ImportExportApi {
  private metadataCache = new Map<string, CampoMetadato[]>()

  constructor(baseUrl: string, options?: ImportExportApiOptions) {
    super(baseUrl, options)
  }

  async getImportFieldMetadata(risorsa: string): Promise<CampoMetadato[]> {
    if (this.metadataCache.has(risorsa)) {
      return this.metadataCache.get(risorsa)!
    }
    const fields = await super.getImportFieldMetadata(risorsa)
    this.metadataCache.set(risorsa, fields)
    return fields
  }
}

const api = new CachedImportExportApi(baseUrl)
<ImportWizard :api="api" :base-url="baseUrl" risorsa="anagrafica" />

Wizard custom: il client va ai composable

IMPORT_EXPORT_API_KEY non è esportato dal pacchetto: il client lo forniscono soltanto ImportWizard ed ExportDialog, dalla prop api o da baseUrl. Per comporre un wizard fuori da quei due componenti non serve: gli step (ImportStepUpload, ImportStepMapping, …) sono presentazionali, ricevono stato con le props e notificano con gli eventi, e i composable accettano il client come argomento.

<script setup lang="ts">
import { ImportExportApi, useImport, ImportStepUpload } from '@pzeta/vue-importexport'

const api = new ImportExportApi(import.meta.env.VITE_IMPORTEXPORT_API_URL)
// Niente provide: il client arriva come argomento
const { file, formato, risorsa, opzioniCsv, loading, errore, setFile, upload, cancelUpload, downloadTemplate } =
  useImport(api)
risorsa.value = 'anagrafica'

// Come ImportWizard: scelto il file, l'upload parte subito
async function onSelectFile(f: File) {
  setFile(f)
  await upload()
}
</script>

<template>
  <ImportStepUpload
    :file="file"
    :formato="formato"
    :risorsa="risorsa"
    :opzioni-csv="opzioniCsv"
    :loading="loading"
    :errore="errore"
    @select-file="onSelectFile"
    @update:opzioni-csv="opzioniCsv = $event"
    @cancel-upload="cancelUpload"
    @download-template="downloadTemplate"
  />
</template>

Props ed eventi di ogni step: Step dell'import.

ImportHistory da solo non funziona. È l'unico componente che recupera il client con useApi() e non ha una prop api; nessun contenitore della libreria lo monta e la chiave non è esportata, quindi montato in una pagina lancia «ImportExportApi non disponibile». Per mostrare lo storico usa useImportHistory(risorsa, client) con una tabella dell'app.

TypeScript

Tutti i tipi rilevanti sono esportati dal package root:

import type {
  IImportClient,
  IExportClient,
  ITemplateClient,
  ImportExportClient,
  ImportExportApiOptions,
} from '@pzeta/vue-importexport'

import { ImportExportApi } from '@pzeta/vue-importexport'