Yayaw
Documentation
Modifier et agir

Exports CSV

Distinguer export du résultat et export de la sélection.

Activez table.export pour ouvrir l'écran d'export depuis le menu Données, et table.bulkExport pour afficher Exporter dans le menu des actions de masse. Avec les deux activés (par défaut), Exporter en masse ouvre ce même écran d'export avec « Sélection (n) » déjà choisi ; avec table.export: false, il écrit directement un CSV de la sélection. Les deux utilisent les valeurs des données.

Exemple de configuration

Partez du démarrage React ou du démarrage Vue. Le fichier suivant complète leur catalogue de produits. Passez cette configuration via getTableConfig en React ou config en Vue.

catalog-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";

export const catalogConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    layoutPreset: "catalog",
    displayModes: ["table", "gallery"],
    defaultDisplayMode: "gallery",
    export: true,
    enableColumnFilters: true,
    enableAdvancedFilters: true,
    showFilterBar: true,
    filterBarColumns: ["status"],
    coloredTags: false,
    gallery: {
      imageColumn: "image",
      titleColumn: "name",
      cardColumnIds: ["price", "stock", "status"],
      aspectRatio: "square",
      imageFit: "cover",
      cardSize: "medium",
      previewSize: "medium",
      showCardLabels: false,
      media: { enabled: true },
    },
  },
});
catalog-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";

export const catalogConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    layoutPreset: "catalog",
    displayModes: ["table", "gallery"],
    defaultDisplayMode: "gallery",
    export: true,
    enableColumnFilters: true,
    enableAdvancedFilters: true,
    showFilterBar: true,
    filterBarColumns: ["status"],
    coloredTags: false,
    gallery: {
      imageColumn: "image",
      titleColumn: "name",
      cardColumnIds: ["price", "stock", "status"],
      aspectRatio: "square",
      imageFit: "cover",
      cardSize: "medium",
      previewSize: "medium",
      showCardLabels: false,
      media: { enabled: true },
    },
  },
});

Le périmètre fait partie du contrat

L’export de barre d’outils récupère le résultat filtré, avec d’autres pages serveur si nécessaire. L’export groupé cible les identifiants sélectionnés, éventuellement sur plusieurs pages. L’action de liste doit respecter la pagination et renvoyer des totaux exacts. Une limite serveur ne doit pas faire passer un export tronqué pour un résultat complet.

Personnaliser la livraison

onExport et onBulkExport permettent à l’application de gérer la livraison. Pour un export volumineux, confiez la génération à un traitement serveur autorisé avec suivi. Exportez les valeurs plutôt que le HTML des cellules, utilisez des en-têtes explicites et vérifiez l’interprétation des textes non fiables par le tableur cible. Consultez l’intégration des requêtes et les actions groupées.

Écran d'export

Exporter ouvre un écran avec Format, Lignes, Colonnes, Valeurs et un nom de fichier, puis un bouton Exporter qui affiche un état de chargement pendant l'export :

  • Format — CSV ; PDF ouvre la boîte de dialogue d'impression du navigateur avec un tableau paginé dont l'en-tête se répète sur chaque page, prêt pour « Enregistrer au format PDF » ; Excel (.xlsx) n'est proposé que lorsque l'hôte fournit actions.exportFile. table.exportFormats limite les formats proposés.

  • Lignes — « Toutes celles de la vue » exporte tous les enregistrements correspondant à la recherche, aux filtres et au tri courants de la vue ; « Sélection (n) » n'exporte que les lignes sélectionnées, et n'apparaît que lorsque des lignes sont sélectionnées.

  • Colonnes — les colonnes visibles dans leur ordre d'affichage, ou toutes les colonnes.

  • Valeurs — « Telles qu'affichées » applique les mêmes formats de devise/nombre, préréglages de date et libellés d'options que les cellules ; « Brutes » écrit les valeurs stockées.

  • Nom de fichier — par défaut <table>-<vue enregistrée>-<AAAA-MM-JJ> (le segment de vue enregistrée est omis pour la vue par défaut), accents supprimés ; modifiez-le avant d'exporter.

Exporter en masse ouvre cet écran

Voici comment les utilisateurs choisissent quoi exporter : sélectionner des lignes, puis cliquer sur Exporter dans la barre d'actions de masse. Avec table.export activé, le bouton Exporter de la barre de masse ouvre ce même écran avec « Sélection (n) » déjà choisi comme périmètre de Lignes — la sélection est conservée, et l'utilisateur peut toujours basculer vers « Toutes celles de la vue ». Un onBulkExport personnalisé continue de prendre le relais ; lorsque l'écran d'export est désactivé (table.export: false), l'export en masse écrit directement un CSV de la sélection. Voir les actions groupées.

Fichiers générés côté serveur avec actions.exportFile

Fournissez actions.exportFile(request) pour générer le fichier côté serveur — requis pour Excel, et recommandé pour le CSV et le PDF sur les grandes tables, car le serveur charge lui-même les enregistrements, sans limite de nombre de lignes côté navigateur. Lorsque exportFile est fourni, il traite tous les formats demandés.

exportFile: async (request) => {
  const response = await fetch("/api/exports", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
  if (!response.ok) {
    throw new Error("Export failed");
  }
  return response.json(); // { url: "https://.../export-2026-09-23.xlsx" }
},

request contient :

{
  format: "csv" | "xlsx" | "pdf";
  scope: "view" | "selection";
  formatted: boolean; // le réglage Valeurs : true pour « Telles qu'affichées », false pour « Brutes »
  fileName: string; // extension incluse
  viewId: string | null; // null pour la vue par défaut
  query: {
    search: string;
    filters: Record<string, unknown>;
    advancedFilters: Record<string, unknown>[];
    advancedFilterJoin: "and" | "or";
    sorting: { id: string; desc: boolean }[];
  }; // la forme de requête de l'action list
  columns: { id: string; header: string; type?: string }[]; // colonnes choisies, dans l'ordre
  selectedRowIds: string[]; // uniquement lorsque scope vaut "selection"
}

Retournez { url } pour un lien de téléchargement (par exemple une URL signée de votre stockage), { blob } pour un fichier construit en ligne, ou rien si votre point d'entrée livre le fichier autrement.

Sans actions.exportFile

Le navigateur construit le fichier lui-même : il charge les lignes correspondantes via list page par page (ou depuis les données locales), ou utilise la sélection courante, puis écrit un CSV UTF-8 avec BOM — pour qu'il s'ouvre correctement dans Excel — ou ouvre la page PDF imprimable. Excel n'est pas disponible dans ce cas ; omettez "xlsx" de table.exportFormats, ou fournissez actions.exportFile pour le proposer. onExport continue de remplacer le fichier CSV intégré, comme avant. Un élément de registre optionnel qui génère l'Excel directement dans le navigateur est prévu.

Copiez cette configuration complète à côté de product-config.ts. Utilisez () => exampleConfig pour getTableConfig en React, ou :config="exampleConfig" en Vue.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Destinations sur mesure

Déclarez destinations dans vos actions de table (getTableActions en React, get-table-actions en Vue) pour envoyer le résultat courant vers un webhook, un workflow n8n ou un connecteur, plutôt que de télécharger un CSV. Chaque entrée est { id, label, kind: "connect" | "share", icon?, hidden?, requiresSelection?, run(context) } ("sync" et "export" restent acceptés comme alias de "connect"). Une destination de connexion peut aussi déclarer connector pour ouvrir l'écran d'envoi de la table au lieu d'exécuter run. Nommez-la seulement d'après l'outil — « n8n », « Slack » — la ligne qui l'ouvre indique déjà ce qui se passe. Les destinations de connexion (kind: "connect") apparaissent sous la ligne Connecter › du menu Données, masquée s'il n'y en a aucune ; les destinations de partage (kind: "share") apparaissent sous Partager ›, après le « Copier le lien » intégré — sans destination de partage, Partager reste une action directe de copie du lien. L'ordre déclaré est conservé et le premier id l'emporte en cas de doublon ; hidden retire une entrée, et requiresSelection ne la propose que lorsque des lignes sont sélectionnées.

run(context) reçoit :

{
  tableId: string;
  tableType?: string;
  viewId: string | null; // null pour la vue par défaut
  query: {
    search: string;
    filters: Record<string, unknown>;
    advancedFilters: Record<string, unknown>[]; // règles actives uniquement
    advancedFilterJoin: "and" | "or";
    sorting: { id: string; desc: boolean }[]; // le tri d'ordre manuel est exclu
  };
  columns: { id: string; header: string }[]; // colonnes visibles, ordre affiché
  selectedRowIds: string[];
  url: string; // lien qui rouvre cette vue
  loadRows: () => Promise<Record<string, unknown>[]>; // tous les enregistrements correspondants
}

query a la même forme que celle reçue par l'action list. Privilégiez le serveur en premier : envoyez query (et viewId/columns) à votre webhook pour que le workflow destinataire récupère lui-même les données depuis votre API. N'appelez loadRows() que si la destination a besoin des lignes depuis le navigateur, par exemple pour une petite table ou des données locales — la fonction charge tous les enregistrements correspondants via list, page par page.

Retournez { message } pour afficher un toast de succès (« Sent » par défaut en React, « Envoyé » en Vue — clé de traduction destinations.done dans les deux éditions) ; levez une exception pour afficher un toast d'erreur avec son message. Une seule destination s'exécute à la fois : les autres sont désactivées et celle en cours affiche un indicateur de chargement.

Les icônes sont un nœud React en React (par exemple <Webhook className="size-4" />) et un composant Vue en Vue (par exemple Webhook depuis lucide-vue-next) ; omettez icon pour utiliser l'icône d'envoi par défaut.

Exemple avec un webhook n8n

Créez un workflow n8n qui démarre par un nœud Webhook et copiez son URL. La fonction run envoie la requête à cette URL et laisse le workflow n8n rappeler votre propre API — côté serveur, sans limite de nombre de lignes côté navigateur :

{
  id: "n8n",
  label: "n8n",
  kind: "connect",
  icon: <Webhook className="size-4" />,
  run: async ({ query, viewId, columns, selectedRowIds }) => {
    const response = await fetch("https://n8n.example.com/webhook/table-export", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query, viewId, columns, selectedRowIds }),
    });
    if (!response.ok) {
      throw new Error("n8n webhook failed");
    }
    return { message: "Sent to n8n" };
  },
}

Dans le workflow n8n, ajoutez un nœud HTTP Request après le déclencheur Webhook pour appeler l'API de votre application avec la query reçue, afin que l'export s'exécute entièrement côté serveur.

Planifier une destination de connexion

Une destination de connexion peut aussi s'exécuter selon une planification. Dans Données › Connecter, une destination qui prend en charge la planification affiche un bouton horloge à côté de son nom. Cliquer sur le nom envoie toujours les données de la vue immédiatement ; l'horloge ouvre la planification de la vue courante :

  • Fréquence : Manuelle (par défaut), Automatique (à chaque modification), Toutes les heures, Quotidienne, Hebdomadaire ou Mensuelle.

  • Moment : selon la fréquence, la minute de l'heure, une heure, un jour de la semaine, ou un jour du mois ou « Dernier jour ».

  • Date de début (facultative) et Fuseau horaire (celui du navigateur par défaut).

Un aperçu indique la prochaine exécution — par exemple « Prochaine : jeu. 24 sept., 09:30 (Europe/Paris) » — en tenant compte du changement d'heure, ainsi que le statut de la dernière exécution lorsque l'application le fournit. L'écran propose Enregistrer, Annuler et Exécuter maintenant. La table ne fait qu'éditer les réglages : votre application les stocke et exécute la destination.

Déclarez schedule sur une destination kind: "connect" :

{
  id: "n8n",
  label: "n8n",
  kind: "connect",
  run: async (context) => sendToN8n(context),
  schedule: {
    frequencies: ["manual", "daily", "weekly"], // facultatif, toutes par défaut
    load: async ({ viewId }) => fetchSchedule(viewId), // ScheduleSettings | null
    save: async (settings, { viewId, query, columns }) => {
      await storeSchedule(viewId, { settings, query, columns });
    },
    status: async ({ viewId }) => fetchLastRun(viewId), // facultatif
  },
}

load, save et status reçoivent le même contexte que run (viewId, query, columns, selectedRowIds, url, loadRows) : une planification appartient donc à une vue. Stockez-la sous viewId avec la requête qu'elle doit rejouer. status renvoie { lastRunAt?, lastResult?: "ok" | "error", message?, nextRunAt? } ; lorsqu'il est fourni, nextRunAt s'affiche à la place de l'aperçu calculé dans le navigateur. « Manuelle » est toujours proposée, même si frequencies l'omet.

ScheduleSettings vaut :

{
  frequency: "manual" | "auto" | "hourly" | "daily" | "weekly" | "monthly";
  minute: number; // 0–59, exécutions toutes les heures
  time: string; // "HH:mm", exécutions quotidiennes, hebdomadaires et mensuelles
  weekday: number; // 0–6, 0 = dimanche, exécutions hebdomadaires
  dayOfMonth: number | "last"; // 1–31 ou le dernier jour du mois
  startDate?: string; // "YYYY-MM-DD", dans le fuseau de la planification
  timeZone: string; // nom IANA, par ex. "Europe/Paris"
}

Exécutez la planification sur votre serveur avec les mêmes règles que l'aperçu : les jours 29, 30 ou 31 se replient sur le dernier jour des mois plus courts ; une heure sautée par un changement d'heure est décalée de l'écart ; une heure répétée au passage à l'heure d'hiver ne s'exécute qu'une fois. Mettez table.schedule: false pour masquer la planification tout en conservant les destinations. Les libellés peuvent être remplacés par les traductions schedule.<clé>. La planification fonctionne de la même façon en React et en Vue.

Pour envoyer vers Notion ou Google Sheets, déclarez un connector pour que la table affiche son écran d'envoi, et appelez les connecteurs serveur optionnels depuis votre propre server action ou route, ainsi que depuis le worker qui exécute la planification.

Exemple de partage

Une destination de partage peut publier le lien de la vue vers un canal via votre propre backend :

{
  id: "slack",
  label: "Slack",
  kind: "share",
  run: async ({ url }) => {
    const response = await fetch("/api/share/slack", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url }),
    });
    if (!response.ok) {
      throw new Error("Could not post to Slack");
    }
    return { message: "Posted to Slack" };
  },
}

Ne placez jamais de jeton de webhook, de clé d'API ou tout autre secret dans le code exécuté par le navigateur. Appelez votre propre point d'entrée backend (/api/share/slack ci-dessus), qui détient les identifiants Slack ou n8n et effectue l'appel authentifié côté serveur.

table.share: false masque le lien Partager intégré ; cela n'affecte pas les destinations personnalisées kind: "share". Voir Configuration pour table.export, table.share, table.schedule et table.connectors, et l'API du provider d'actions pour le champ destinations complet.

Envoyer vers un connecteur

Une destination de connexion peut déclarer connector à la place de run (ou en plus). Sa ligne dans Données › Connecter ouvre alors un écran d'envoi géré par la table : votre application se contente de lister les cibles, de décrire leurs champs et d'envoyer via sa propre fonction serveur. L'écran fonctionne de la même façon en React et en Vue. Associez-le aux connecteurs serveur pour envoyer vers Google Sheets ou Notion. La correspondance des colonnes est le même composant que celui de l'écran d'import, dans l'autre sens. Un connecteur peut aussi importer depuis sa cible ou garder les deux côtés synchronisés : voir Synchroniser depuis l'écran du connecteur.

Ce que voit l'utilisateur

L'écran présente une liste déroulante par choix, dans cet ordre :

  • Destination (ou le labels.target de la destination, par exemple « Tableur ») : la cible dans laquelle écrire, comme un tableur ou une base de données Notion, avec « Choisir… » tant qu'aucune n'est choisie. Avec allowTargetInput, un champ en dessous accepte un lien ou un identifiant collé, et Utiliser le résout en cible. Actualiser la liste relit les cibles, et la liste peut proposer En créer une à partir des colonnes de la table… (voir Vérification et préparation de la destination).

  • Section (ou labels.child, par exemple « Onglet ») : une seconde liste quand la cible a des enfants, comme les onglets d'un tableur. Le premier est choisi par défaut.

  • Sens, seulement quand le connecteur en propose plusieurs : envoyer vers la cible, importer depuis elle ou garder les deux synchronisés. Voir Synchroniser depuis l'écran du connecteur.

  • Colonnes : Visibles (les colonnes visibles de la vue, par défaut) ou Toutes, avec leur nombre.

  • Envoyer chaque colonne vers : une liste par colonne, positionnée sur le champ de la cible qui porte le même nom (accents, casse, espaces, - et _ ignorés, champs de type compatible en premier). Chaque colonne peut aussi aller vers un Nouveau champ « … » portant son nom, si la cible accepte de nouveaux champs, ou être écartée avec Ne pas envoyer. Les champs que la colonne ne peut pas remplir, ou qu'une autre colonne a déjà, restent listés mais désactivés, avec la raison, et le titre de page d'une base Notion vient en premier.

  • Identifier les lignes par : le champ clé comparé à l'identifiant de chaque enregistrement. Par défaut Yayaw ID, proposé même si la cible ne l'a pas encore lorsqu'elle accepte de nouveaux champs.

  • Mode, affiché seulement quand la destination propose les deux : Mettre à jour et ajouter (upsert : les lignes de même clé sont mises à jour, les autres ajoutées) ou Tout remplacer (la cible est vidée, puis réécrite). Une explication d'une ligne suit le choix.

  • Enregistrements : Tous ceux de la vue, ou Sélectionnés (n) lorsque des lignes sont sélectionnées (choix par défaut dans ce cas).

  • Vérification de la destination, quand quelque chose dans la cible risque de casser l'envoi : ce qu'il faut corriger, ce que Préparer peut corriger pour vous et ce qui est bon à savoir. Voir Vérification et préparation de la destination.

Envoyer reste désactivé tant que la vérification de la destination trouve un problème bloquant. Il vérifie d'abord les réglages et affiche le premier problème sous le formulaire (par exemple « Choisissez une destination. » ou « Choisissez au moins une colonne à envoyer. »). Il enregistre ensuite les réglages avec save et appelle push. Le bouton affiche « Envoi… » pendant ce temps. Le résultat indique les compteurs non nuls (« 3 créés, 5 mis à jour, 1 en échec »), les trois premiers échecs, le nombre d'avertissements et une mention quand la limite de lignes de la cible est atteinte, avec Terminé et Renvoyer. Lorsque load renvoie les réglages enregistrés pour la vue, l'écran s'ouvre avec la même cible, le même onglet et la même correspondance.

Les erreurs s'affichent sous le formulaire dans la langue de l'utilisateur, et non dans un toast. not_shared nomme le compte avec lequel partager : « Partagez cette destination avec [email protected], puis renvoyez. » Les autres codes (unauthorized, forbidden, not_found, rate_limited, api_disabled…) ont chacun leur message. Une erreur inconnue affiche son propre message.

Le bouton horloge de planification reste à côté du nom de la destination : une destination à connecteur peut donc aussi s'exécuter selon une planification.

Déclarer un connecteur

{
  id: "google-sheets",
  label: "Google Sheets",
  kind: "connect",
  connector: {
    labels: { target: "Tableur", child: "Onglet" }, // facultatif
    targets: (context) => listTargets(), // ConnectorTarget[]
    allowTargetInput: { // facultatif
      label: "Ou collez le lien d'un tableur",
      placeholder: "https://docs.google.com/spreadsheets/d/…",
      resolve: (input, context) => resolveTarget(input), // ConnectorTarget
    },
    describe: ({ targetId, childId }, context) => describeTarget(targetId, childId), // ConnectorSchema
    modes: ["upsert", "replace"], // facultatif, ["upsert"] par défaut
    load: ({ viewId }) => loadSettings(viewId), // facultatif : ConnectorSettings | null
    save: (settings, { viewId }) => saveSettings(viewId, settings), // facultatif
    push: (settings, context) => pushRows(settings, context), // ConnectorPushResult ou { error }
    help: { // facultatif
      notShared: ({ serviceAccountEmail }) =>
        `Partagez le tableur avec ${serviceAccountEmail} en tant qu'éditeur.`,
    },
  },
}

Chaque fonction s'exécute dans le navigateur et peut renvoyer une valeur ou une promesse ; appelez-y vos propres fonctions serveur. targets, describe, load et save reçoivent le contexte de destination de la vue (viewId, query, columns, selectedRowIds, url, loadRows) : les réglages appartiennent donc à une vue, comme une planification.

  • targets renvoie { id, label, description?, children?: { id, label }[] }[]. Une cible mémorisée par load mais que targets ne liste plus reste proposée, sous son identifiant.

  • allowTargetInput.resolve transforme le texte collé en une cible, ajoutée à la liste et sélectionnée. Levez invalid_target quand le texte est inutilisable.

  • describe renvoie { provider?, fields: { name, id?, type?, options?, index? }[], keyFields?, allowNewFields? }. id (un identifiant de propriété Notion) et index (une position de colonne dans la feuille) permettent à un champ renommé de garder sa colonne, et provider ("notion" ou "sheets") rend la vérification de la destination stricte. keyFields liste les champs qui peuvent identifier les lignes (tous par défaut). allowNewFields propose les choix Nouveau champ et Yayaw ID pour une cible qui peut ajouter des champs, comme une feuille dont les en-têtes sont écrits au premier envoi. Un fields vide convient pour un nouvel onglet.

  • push reçoit les réglages et le contexte d'envoi : le même contexte, plus scope ("view" ou "selection"), et columns limité aux colonnes envoyées, chacune avec son type de table. Pour "view", selectedRowIds est vide ; pour "selection", selectedRowIds et loadRows ne couvrent que les enregistrements sélectionnés. Transmettez query, viewId et selectedRowIds à votre serveur pour qu'il charge lui-même les lignes.

  • push se résout en { created, updated, skipped, failed, failures, warnings, warningCount, truncated }, le résultat des connecteurs serveur, qui peut être renvoyé tel quel. En cas d'échec, renvoyez { error: { code, details? } } ou levez une erreur portant code et details. targets, resolve, describe et load signalent leurs échecs en levant une erreur.

  • labels renomme les listes Destination et Section pour cette destination. help.notShared(details) remplace le message de not_shared.

Les réglages qui passent les vérifications sont enregistrés à chaque envoi, quel que soit le retour de push. Un save en échec affiche son message à côté du résultat sans le masquer.

ConnectorSettings, ce que renvoie load et ce que reçoivent save et push, vaut :

{
  targetId: string;
  childId?: string; // l'enfant choisi, par ex. l'onglet
  mode: "upsert" | "replace";
  keyField: string; // par ex. "Yayaw ID"
  keyFieldId?: string; // l'identifiant de propriété Notion de la clé
  keyFieldIndex?: number; // la position de la clé dans la feuille
  mapping: {
    columnId: string;
    field: string | null; // null : non envoyé
    fieldId?: string; // identifiant de propriété Notion, cherché avant le nom
    fieldIndex?: number; // position dans la feuille, suit un en-tête renommé
  }[];
  columns?: "visible" | "all";
}

mapping contient une entrée par colonne de la vue. Les colonnes absentes de l'écran (les colonnes masquées lorsque Visibles est choisi) et celles réglées sur Ne pas envoyer ont field: null. run devient facultatif pour une destination dotée de connector ; conservez-le si vous risquez de mettre table.connectors: false, qui masque les écrans et fait de nouveau exécuter run par la ligne.

Fonctions utilitaires

connector-flow.ts (components/ui/yayaw-table/utils/connector-flow.ts en React, components/ui/yayaw-table-vue/connector-flow.ts en Vue) contient le parcours et ses types. Il ne dépend d'aucun framework : votre code serveur peut donc aussi importer ses fonctions utilitaires :

  • defaultConnectorMapping(columns, fields, { allowNewFields?, keyField? }) : la correspondance que propose l'écran.

  • defaultConnectorKeyField(schema) et connectorKeyFields(schema) : la clé par défaut (« Yayaw ID » lorsqu'elle est proposée) et les clés proposées.

  • resolveConnectorSettings({ columns, modes, saved, schema, target }) : les réglages enregistrés fusionnés avec les valeurs par défaut d'une cible, en gardant ce qui s'applique encore.

  • validateConnectorSettings(settings, { schema, modes, target }) : les problèmes qui empêcheraient un envoi, sous la forme { code, columnId?, field? }[]. Vérifiez-les de nouveau sur le serveur avant d'envoyer.

  • toConnectorMapping(settings) : { keyProperty, properties }, la forme de correspondance du connecteur serveur Notion, plus keyPropertyId et propertyIds (Notion) et fieldIndexes et keyFieldIndex (feuilles) quand les réglages les ont enregistrés.

  • describeSchemaReport, describeSchemaFixes, connectorSchemaBlocker, connectorFieldOptions, connectorNewTargetColumns et schemaIssueMessage(issue, { t, target, columns }) : le bloc « Vérification de la destination », la confirmation de Préparer, le problème qui désactive Envoyer, les choix de correspondance avec leurs raisons et les colonnes d'une nouvelle destination, pour un écran sur mesure. Voir Vérification et préparation de la destination.

  • describePushResult(result, t), describePushDetails(result, t, help?) et connectorErrorMessage(code, details, t, help?) : les messages de résultat et d'erreur. connectorLabels(locale, translate?) construit t, par exemple pour rendre compte d'un envoi planifié avec les mêmes mots.

  • createConnectorFlow et connectorScreenFields : la machine à états et la liste de champs que rendent les deux éditions, pour un écran sur mesure.

  • toSyncPreview(plan, { limit?, rowLabel? }) et toSyncRunResult(result, plan?) : le plan et le résultat du moteur de synchronisation, tels que preview et sync les renvoient. Voir Synchroniser depuis l'écran du connecteur.

  • connectorDirections(connector, syncEnabled?) et connectorConflictRules(rules) : les sens et les règles de conflit que l'écran propose.

  • toPendingConflicts(pending, { rowLabel? }), describeConflictRules, connectorConflictRulesView, canResolveConflicts et describePendingConflicts : les conflits laissés à une personne, tels que listConflicts les renvoie, et les blocs « Règles définies par votre application » et « Conflits à résoudre » pour un écran sur mesure. Voir Règles de conflit définies par votre application.

DEFAULT_CONNECTOR_KEY_FIELD vaut "Yayaw ID".

Exemple : Google Sheets

Cette destination envoie une vue vers un onglet Google Sheets avec le connecteur serveur Google Sheets. Le code du navigateur se contente d'appeler des server actions :

google-sheets-destination.ts
import type {
  ConnectorSettings,
  ConnectorTargetRef,
} from "@/components/ui/yayaw-table/utils/connector-flow";
import type { ConnectorPushContext } from "@/components/ui/yayaw-table/utils/data-destinations";
import {
  describeSheet,
  listSheets,
  loadSheetSettings,
  pushViewToSheet,
  resolveSheet,
  saveSheetSettings,
} from "./google-sheets-actions";

// Les server actions renvoient les échecs comme des données ; les lever affiche le message localisé.
async function orThrow<T>(result: T | { error: { code: string } }): Promise<T> {
  if (result && typeof result === "object" && "error" in result) {
    throw result;
  }
  return result as T;
}

export const googleSheetsDestination = {
  id: "google-sheets",
  label: "Google Sheets",
  kind: "connect" as const,
  connector: {
    labels: { target: "Tableur", child: "Onglet" },
    targets: async () => orThrow(await listSheets()),
    allowTargetInput: {
      label: "Ou collez le lien d'un tableur",
      placeholder: "https://docs.google.com/spreadsheets/d/…",
      resolve: async (link: string) => orThrow(await resolveSheet(link)),
    },
    describe: async (target: ConnectorTargetRef) =>
      orThrow(await describeSheet(target)),
    modes: ["upsert", "replace"] as ("upsert" | "replace")[],
    load: ({ viewId }: { viewId: string | null }) => loadSheetSettings(viewId),
    save: (settings: ConnectorSettings, { viewId }: { viewId: string | null }) =>
      saveSheetSettings(viewId, settings),
    push: (settings: ConnectorSettings, context: ConnectorPushContext) =>
      pushViewToSheet({
        settings,
        viewId: context.viewId,
        query: context.query,
        selectedRowIds: context.scope === "selection" ? context.selectedRowIds : null,
      }),
  },
};

Ajoutez googleSheetsDestination aux destinations de vos actions de table. En Vue, importez les types depuis @/components/ui/yayaw-table-vue/connector-flow et @/components/ui/yayaw-table-vue/data-destinations.

Les server actions vérifient l'utilisateur, utilisent le module Google Sheets et renvoient les erreurs comme des données :

google-sheets-actions.ts
"use server";
import {
  isConnectorError,
  toConnectorRows,
} from "@/components/ui/yayaw-table/connectors/connector-model";
import {
  type GoogleServiceAccountCredentials,
  getSpreadsheet,
  parseServiceAccountKey,
  parseSpreadsheetId,
  pushRowsToSheet,
  readHeaderRow,
} from "@/components/ui/yayaw-table/connectors/google-sheets";
import {
  toConnectorMapping,
  type ConnectorSettings,
  type ConnectorTarget,
  type ConnectorTargetRef,
} from "@/components/ui/yayaw-table/utils/connector-flow";
import type { DataDestinationQuery } from "@/components/ui/yayaw-table/utils/data-destinations";
import {
  loadConnection,
  loadViewRows,
  readConnectorSettings,
  rememberSpreadsheet,
  requireSpreadsheet,
  requireUser,
  savedSpreadsheets,
  writeConnectorSettings,
} from "@/server/app";

async function sheetsAccess() {
  const user = await requireUser();
  const connection = await loadConnection(user, "google-sheets");
  return { user, credentials: parseServiceAccountKey(connection.secret) };
}

// `not_shared` conserve `details.serviceAccountEmail` pour le message de l'écran.
function asFailure(error: unknown) {
  if (isConnectorError(error)) {
    return { error: { code: error.code, details: error.details } };
  }
  throw error;
}

async function toTarget(
  credentials: GoogleServiceAccountCredentials,
  spreadsheetId: string
): Promise<ConnectorTarget> {
  const spreadsheet = await getSpreadsheet(credentials, spreadsheetId);
  return {
    id: spreadsheet.id,
    label: spreadsheet.title,
    children: spreadsheet.sheets.map((tab) => ({ id: tab.title, label: tab.title })),
  };
}

export async function listSheets() {
  const { user, credentials } = await sheetsAccess();
  try {
    const ids = await savedSpreadsheets(user);
    return await Promise.all(ids.map((id) => toTarget(credentials, id)));
  } catch (error) {
    return asFailure(error);
  }
}

export async function resolveSheet(link: string) {
  const { user, credentials } = await sheetsAccess();
  try {
    const target = await toTarget(credentials, parseSpreadsheetId(link));
    await rememberSpreadsheet(user, target.id);
    return target;
  } catch (error) {
    return asFailure(error);
  }
}

export async function describeSheet(target: ConnectorTargetRef) {
  const { user, credentials } = await sheetsAccess();
  await requireSpreadsheet(user, target.targetId);
  try {
    // Chaque cible liste ses onglets : l'écran en transmet donc toujours un.
    const headers = await readHeaderRow(credentials, target.targetId, target.childId ?? "");
    return {
      provider: "sheets" as const, // la vérification connaît les feuilles
      fields: headers.map((name, index) => ({ name, index })), // la position garde la colonne d'un en-tête renommé
      keyFields: headers.length > 0 ? headers : undefined,
      allowNewFields: true, // les en-têtes manquants sont ajoutés à la fin
    };
  } catch (error) {
    return asFailure(error);
  }
}

export async function loadSheetSettings(viewId: string | null) {
  const user = await requireUser();
  return readConnectorSettings(user, viewId, "google-sheets");
}

export async function saveSheetSettings(viewId: string | null, settings: ConnectorSettings) {
  const user = await requireUser();
  await writeConnectorSettings(user, viewId, "google-sheets", settings);
}

export async function pushViewToSheet(input: {
  settings: ConnectorSettings;
  viewId: string | null;
  query: DataDestinationQuery;
  selectedRowIds: string[] | null;
}) {
  const { user, credentials } = await sheetsAccess();
  const { settings } = input;
  // Ne faites jamais confiance au navigateur : l'utilisateur peut-il envoyer cette vue vers ce tableur ?
  await requireSpreadsheet(user, settings.targetId);
  const records = await loadViewRows(user, input.viewId, input.query, input.selectedRowIds);
  try {
    return await pushRowsToSheet({
      credentials,
      spreadsheetId: settings.targetId,
      sheetTitle: settings.childId,
      keyColumn: settings.keyField,
      mode: settings.mode,
      // Un en-tête de feuille par colonne associée ; les champs `null` ne sont pas envoyés.
      columns: settings.mapping.flatMap((entry) =>
        entry.field ? [{ id: entry.columnId, header: entry.field }] : []
      ),
      // Suit les en-têtes renommés par leur position enregistrée.
      fieldIndexes: toConnectorMapping(settings).fieldIndexes,
      keyColumnIndex: settings.keyFieldIndex,
      rows: toConnectorRows(records),
    });
  } catch (error) {
    return asFailure(error);
  }
}

Les fonctions importées depuis @/server/app représentent votre propre code : l'authentification, la clé du compte de service stockée, les tableurs ajoutés par chaque utilisateur, les réglages par vue et le chargement des lignes de la vue sur le serveur avec les autorisations de l'utilisateur (seulement selectedRowIds lorsqu'il est fourni). En Vue, exposez les mêmes fonctions comme routes serveur Nuxt et appelez-les avec $fetch.

Pour Notion, targets transforme listNotionDatabases(token) en { id, label: title }, et describe transforme getNotionDatabaseSchema(token, targetId).properties en { name, id, type, options }, avec provider: "notion", keyFields limité aux propriétés titre, texte et nombre, sans allowNewFields. Proposez seulement "upsert", et envoyez avec pushRowsToNotionDatabase({ token, databaseId: settings.targetId, mapping: toConnectorMapping(settings), rows }).

Pour exécuter le même envoi selon une planification, donnez aussi une schedule à la destination : le worker lit les ConnectorSettings enregistrés pour la vue et appelle le même code serveur.

Vérification et préparation de la destination

L'écran vérifie la destination avant tout envoi : une base Notion ou une feuille qui a dérivé ne casse donc jamais un envoi ou une synchronisation à mi-chemin. La vérification s'exécute à l'ouverture d'une destination enregistrée et après chaque changement de correspondance, à partir des champs renvoyés par describe, avec les règles de Garder la destination saine.

Ce que voit l'utilisateur :

  • Vérification de la destination liste les problèmes sous les choix, en trois groupes : À corriger avant l’envoi, Corrigeable pour vous et Bon à savoir. Chaque ligne est en langage courant, par exemple « Prix : la propriété Notion est de type Texte, type Nombre attendu. » ou « Propriété « Yayaw ID » manquante : elle contient l’identifiant de chaque enregistrement. »

  • Un problème bloquant désactive Envoyer, Synchroniser et Prévisualiser les changements, avec la raison en dessous : « À corriger d’abord : … ». Les problèmes corrigeables et les avertissements ne bloquent jamais.

  • Avec prepareTarget, les problèmes corrigeables ajoutent Préparer la base Notion (ou Préparer la feuille, ou « Préparer » suivi du nom de la destination). Le bouton montre d'abord ce qui va changer (« Créer « Yayaw ID » (Texte) pour les identifiants », « Ajouter 2 options à « Statut » : Bloqué, Relecture ») avec « Ces changements seront faits dans Notion. Rien n’est supprimé ni renommé. » et Faire ces changements ou Annuler. Après les changements, il relit la destination et indique « 3 changements faits dans Notion. » ou « Rien à changer : Notion était déjà prêt. »

  • Un champ renommé dans la destination est suivi, pas perdu : la vérification affiche « Renommé dans Notion : Prix → Coût », l'envoi continue d'écrire le champ renommé, et Mettre à jour la correspondance enregistre le nouveau nom. Dans une feuille, un en-tête renommé est suivi par sa position.

  • Dans Envoyer chaque colonne vers, les champs que la colonne ne peut pas remplir restent listés mais désactivés, avec la raison : « Marge (Formule, lecture seule) », « Responsable (Personne, pas encore pris en charge) », « Statut (Sélection, incompatible) ». Un champ qu'une autre colonne a déjà est aussi désactivé : « Coût (utilisé par Prix) ». Les valeurs par défaut ne choisissent jamais un tel champ. Pour une base Notion, la première ligne est le titre de page, « Nom (titre de page) », avec la colonne qui le remplit.

  • Les options manquantes d'une propriété Notion Statut bloquent l'envoi avec une indication, car l'API de Notion ne peut pas les ajouter : « Statut : 2 options de statut manquantes dans Notion : Bloqué, Relecture. Ajoutez-les dans Notion, puis vérifiez à nouveau. »

  • Sous la liste des destinations, Actualiser la liste relit les destinations, et help.missingTarget peut expliquer comment faire apparaître une destination absente, par exemple « Partagez la page avec votre intégration dans Notion : ••• › Connexions ».

  • Avec createTarget, la liste propose En créer une à partir des colonnes de la table… : la personne choisit où la créer (Créer dans) et un Nom, puis Créer. La nouvelle destination est ajoutée à la liste, choisie et décrite.

La déclarer :

connector: {
  // targets, describe, load, save, push… comme ci-dessus
  describe: async (ref, context) => ({
    provider: "notion", // vérifications strictes pour Notion ("sheets" pour Google Sheets)
    fields: await describeFields(ref.targetId), // { name, id?, type?, options?, index? }[]
  }),
  checkSchema: (settings, context) => checkNotionTarget(settings, context.viewId), // facultatif : SchemaReport ou { error }
  prepareTarget: (fixes, settings) => prepareNotionTarget(fixes, settings), // facultatif : { applied } ou { error }
  createTarget: { // facultatif
    parents: () => listParentPages(), // { id, label }[], par ex. issus de listNotionPages
    create: ({ parentId, title, columns }) => createDatabase(parentId, title, columns), // ConnectorTarget ou { error }
  },
  help: {
    missingTarget: "Partagez la page avec votre intégration dans Notion : ••• › Connexions",
  },
},
  • describe donne à chaque champ son id stable (un identifiant de propriété Notion) ou son index (une position de colonne dans la feuille), et provider ("notion" ou "sheets") rend la vérification stricte sur les types, les options et les champs manquants. Sans provider, la vérification ne regarde que les types et les doublons, et les champs manquants sont laissés à l'envoi.

  • checkSchema remplace la vérification dans le navigateur par une vérification sur votre serveur, par exemple avec un schéma à jour. L'écran la lance après les mêmes événements, et la dernière l'emporte. Les réglages portent les noms enregistrés des champs renommés.

  • prepareTarget reçoit les corrections du rapport et appelle prepareNotionDatabase ou prepareSheet sur votre serveur. Les deux ne font qu'ajouter : les lancer deux fois est sans risque.

  • createTarget.parents liste où une nouvelle destination peut aller, et createTarget.create reçoit les colonnes visibles avec leurs types et leurs options (couleurs nommées comme dans Notion) et renvoie la nouvelle destination, par exemple issue de createNotionDatabase. createTarget.label renomme « En créer une à partir des colonnes de la table… ».

Le côté serveur de ces fonctions se trouve dans Fonctions hôtes pour l'écran. Chaque entrée de correspondance enregistre désormais fieldId et fieldIndex, et les réglages enregistrent keyFieldId et keyFieldIndex : transmettez-les, ce que font déjà toConnectorMapping(settings) et toSyncMapping(settings, columns). Une exécution planifiée devrait vérifier d'abord la destination et se mettre en pause quand schemaBlocksRun(report) vaut true : voir Exécutions planifiées.

Synchroniser depuis l'écran du connecteur

Un connecteur ne fait pas qu'envoyer. Quand il déclare directions avec sync (et, idéalement, preview), l'écran peut aussi importer depuis la cible ou garder les deux côtés synchronisés. Il s'appuie sur le moteur de synchronisation bidirectionnelle, côté serveur.

Sens, règles et prévisualisation

Une liste Sens apparaît après la cible et sa section quand le connecteur propose plusieurs sens : Envoyer vers Google Sheets (push), Importer depuis Google Sheets (pull) ou Garder les deux synchronisés (two-way), d'après la cible choisie. L'envoi garde l'écran décrit plus haut et appelle toujours push, avec ses modes et les enregistrements sélectionnés. L'import et la synchronisation changent la suite de l'écran :

  • Importer chaque champ dans (import) ou Synchroniser chaque champ avec (synchronisation) : une liste par champ de la cible, positionnée sur la colonne de la table qu'il remplit, ou Ne pas importer / Ne pas synchroniser. Chaque champ affiche sa première valeur d'exemple (« ex. Terminé ») et un badge qui compte les exemples que la colonne choisie ne peut pas recevoir (« 2 non convertibles »). Choisir une colonne déjà utilisée par un autre champ la libère de celui-ci. La synchronisation liste aussi les champs qu'elle ajoutera à la cible.

  • Identifier les lignes par : le champ clé, comme pour un envoi.

  • Si les deux côtés ont changé (synchronisation seulement) : Cette table l'emporte, Google Sheets l'emporte ou La dernière modification l'emporte, chacun avec une explication d'une ligne. Sans date de modification, comme dans un tableur, la table l'emporte.

  • Enregistrements supprimés : ce qui arrive à un enregistrement lié supprimé d'un côté. Seulement signaler (par défaut) le liste et ne supprime rien, Ignorer n'en fait rien, et Supprimer de l'autre côté le supprime aussi de l'autre côté. Ce dernier choix affiche une case de confirmation, et Synchroniser reste désactivé tant qu'elle n'est pas cochée et, quand le connecteur sait prévisualiser, tant que les changements n'ont pas été prévisualisés.

Prévisualiser les changements compare les deux côtés sans rien écrire. L'écran affiche ce qui sera créé, mis à jour et supprimé Dans Google Sheets et Dans cette table, le nombre d'enregistrements inchangés, les enregistrements supprimés d'un côté qui sont seulement signalés, les clés partagées par plusieurs enregistrements (laissés de côté) et les premiers conflits : l'enregistrement et la colonne, les deux valeurs et celle qui l'emporte, ou la façon dont les règles de votre application le règlent. Toute modification des réglages efface la prévisualisation ; Prévisualiser à nouveau la relance.

Synchroniser (Importer pour un import) enregistre les réglages avec save, exécute la synchronisation et recharge la table. Le résultat indique par exemple « Dans Google Sheets : 2 créés, 1 mis à jour · Dans cette table : 3 créés », avec les échecs, les enregistrements signalés, une mention quand une limite est atteinte et la raison d'un arrêt anticipé, puis Terminé et Prévisualiser à nouveau. Une synchronisation couvre toujours toute la vue, jamais la sélection.

Données › Importer liste chaque connecteur capable d'importer comme une source, « Depuis Google Sheets », qui ouvre cet écran avec le sens positionné sur l'import. La planification d'une destination à connecteur exécute le sens enregistré pour la vue, et son résumé le nomme, par exemple « Tous les jours à 09:00 (Europe/Paris) · Synchronisé ».

Déclarer les sens

connector: {
  // targets, describe, load, save, push… comme plus haut
  directions: ["push", "pull", "two-way"], // par défaut ["push"]
  conflictRules: ["table-wins", "target-wins"], // facultatif, par défaut les trois
  preview: (settings, context) => previewSync(settings, context.viewId), // SyncPreview ou { error }
  sync: (settings, context) => runSync(settings, context.viewId), // SyncRunResult ou { error }
},
  • directions liste les sens à proposer, dans l'ordre. pull et two-way ne sont proposés que si sync est déclaré ; sans eux, l'écran reste un écran d'envoi.

  • conflictRules limite les règles proposées pour la synchronisation. Écartez latest-wins pour une cible sans date de modification, comme Google Sheets, où elle se comporterait comme table-wins.

  • preview et sync reçoivent les réglages et le contexte d'envoi, avec scope toujours à "view". Ils renvoient les données ci-dessous, ou { error: { code, details? } } comme push. preview est facultatif : sans lui, Prévisualiser les changements est masqué et une synchronisation qui supprime ne demande que la confirmation ; déclarez-le donc dès que vous proposez l'import ou la synchronisation.

  • describe peut donner à chaque champ quelques valeurs dans sample : l'écran affiche la première et compte celles que la colonne associée ne sait pas convertir.

ConnectorSettings gagne trois champs, toujours renseignés par l'écran (un sens imposé depuis Données › Importer, sinon celui enregistré s'il est encore proposé, sinon push, table-wins et flag) :

{
  // targetId, childId, mode, keyField, mapping, columns comme plus haut
  direction?: "push" | "pull" | "two-way";
  conflictRule?: "table-wins" | "target-wins" | "latest-wins"; // synchronisation
  deletePolicy?: "flag" | "ignore" | "propagate"; // import et synchronisation
}

Pour l'import et la synchronisation, mapping garde une entrée par colonne de la table : field est le champ de la cible avec lequel cette colonne se synchronise, ou null. Un import écarte les champs que la cible n'a pas.

preview renvoie un SyncPreview :

{
  createInTarget: number; updateInTarget: number; deleteInTarget: number;
  createInTable: number; updateInTable: number; deleteInTable: number;
  flagged: number; // supprimés d'un côté, seulement signalés
  duplicates: number; // clés partagées par plusieurs enregistrements
  unchanged: number;
  conflicts: { rowId?, rowLabel?, columnId, tableValue, targetValue, resolution: "table" | "target" }[]; // les premiers
  conflictCount?: number; // tous les conflits, par défaut conflicts.length
}

sync renvoie un SyncRunResult : { applied, failed, failures, flagged, truncated, stopped? }, où applied contient les compteurs non nuls écrits de chaque côté (createInTarget, updateInTable…), failures les premiers échecs ({ code, rowId?, rows? }) et stopped la raison pour laquelle une erreur d'autorisation ou une annulation a interrompu l'exécution.

Exécuter la synchronisation côté serveur

preview et sync appellent deux fonctions serveur qui lisent les deux côtés, planifient avec planSync, appliquent avec applySyncPlan et transforment la sortie du moteur en données simples avec toSyncPreview et toSyncRunResult :

sync-actions.ts
"use server";
import {
  toSyncPreview,
  toSyncRunResult,
  type ConnectorSettings,
} from "@/components/ui/yayaw-table/utils/connector-flow";
import {
  applySyncPlan,
  planSync,
  toSyncMapping,
} from "@/components/ui/yayaw-table/connectors/sync-engine";

async function plan(settings: ConnectorSettings, viewId: string) {
  const user = await requireUser();
  const { columns, records, target, state } = await loadSyncInputs(user, settings, viewId);
  const mapping = toSyncMapping(settings, columns);
  return {
    target,
    records,
    plan: planSync({
      direction: settings.direction ?? "push",
      conflictRule: settings.conflictRule,
      deletePolicy: settings.deletePolicy,
      mapping,
      tableRecords: records.map((record) => ({ id: record.id, values: record })),
      targetRecords: await target.read(),
      state,
    }),
  };
}

export async function previewSync(settings: ConnectorSettings, viewId: string) {
  const { plan: planned, records } = await plan(settings, viewId);
  return toSyncPreview(planned, {
    rowLabel: (id) => records.find((record) => record.id === id)?.name,
  });
}

export async function runSync(settings: ConnectorSettings, viewId: string) {
  const { plan: planned, target } = await plan(settings, viewId);
  const result = await applySyncPlan(planned, { target, table: tableRows(viewId) });
  await syncStates.set(settings.targetId, viewId, result.state);
  return toSyncRunResult(result, planned);
}

requireUser, loadSyncInputs (les colonnes et les lignes de la vue, l'adaptateur de cible comme createSheetSyncTarget ou createNotionSyncTarget, et le SyncState enregistré), tableRows et syncStates représentent votre propre code ; voir Exécuter une synchronisation dans un worker. toSyncPreview garde les 5 premiers conflits (limit) et nomme leurs enregistrements avec rowLabel. toSyncRunResult compte les écritures de chaque côté et reprend flagged du plan. Vérifiez de nouveau les réglages côté serveur, et verrouillez la destination et la vue pendant une synchronisation. Une exécution planifiée utilise les réglages enregistrés : la planification d'un connecteur synchronisé exécute donc une synchronisation bidirectionnelle.

Règles de conflit définies par votre application

Votre serveur peut décider des conflits dans le code, colonne par colonne : voir Règles de conflit dans le code. Ces règles s'exécutent sur votre serveur, là où s'exécute planSync ; le connecteur déclare ce que l'écran affiche, ainsi que deux fonctions facultatives pour les conflits laissés à une personne :

connector: {
  // targets, describe, push, directions, preview, sync… comme ci-dessus
  conflicts: {
    ownership: { price: "target" },
    columnRules: { tags: "merge", notes: "manual" },
    lock: true, // la liste « Si les deux côtés ont changé » est en lecture seule
    allowManual: true, // par défaut : proposer les conflits à résoudre
  },
  listConflicts: (settings, context) => listSyncConflicts(settings.targetId, context.viewId), // PendingConflict[] ou { error }
  resolveConflicts: (resolutions, settings, context) =>
    resolveSyncConflicts(settings.targetId, context.viewId, resolutions), // SyncRunResult ou { error }
},

Déclarez les mêmes ownership et columnRules que ceux que votre serveur passe à planSync : conflicts indique seulement à l'écran quoi afficher, il ne décide de rien.

  • Règles définies par votre application apparaît sous Si les deux côtés ont changé pour une synchronisation (sous Enregistrements supprimés pour un import, avec la propriété seulement, puisque seule la propriété s'y applique). Le bloc liste une phrase par règle, les colonnes possédées d'abord : « Prix : Google Sheets fait foi », « Étiquettes : fusionné », « Notes : décidé par vous », « Nom : cette table l'emporte ». Avec lock: true, une icône de cadenas et « Votre application décide des conflits ; ces règles ne se modifient pas ici. » s'affichent, et la liste de la règle de conflit est désactivée.

  • Prévisualiser les changements annote chaque conflit « Google Sheets l'emporte », « Fusionné » (avec le résultat), « À décider par vous », « Décidé par votre application » ou « Laissé tel quel pour l'instant ». Les colonnes réécrites par leur propriétaire sont listées sous « Repris du côté propriétaire (N) », chacune marquée « Appartient à Google Sheets », et une note indique « N conflits attendront votre décision. »

  • Avec listConflicts et resolveConflicts (et allowManual différent de false), l'écran charge les conflits en attente une fois la cible décrite, après chaque synchronisation et après chaque résolution. Conflits à résoudre (N) ouvre la liste : chaque conflit affiche son enregistrement, sa colonne et les deux valeurs, formatées selon le type de colonne (nombres et dates dans la langue de la table, oui/non, listes), avec Garder la valeur de la table et Garder la valeur de Google Sheets. Garder toutes les valeurs de la table et Garder toutes les valeurs de Google Sheets règlent tous les conflits d'un coup. La table se recharge après chaque résolution, et les autres colonnes de la ligne continuent de se synchroniser entre-temps.

listConflicts renvoie PendingConflict { rowId, remoteId?, rowLabel?, columnId, tableValue, targetValue, baseValue?, detectedAt? }[] ; toPendingConflicts(state.pendingConflicts, { rowLabel }) le construit à partir de l'état de synchronisation. resolveConflicts reçoit PendingConflictResolution { rowId, columnId, choice: "table" | "target" | { value } }[] et renvoie un SyncRunResult, comme sync.

SyncPreview gagne ces champs facultatifs, que toSyncPreview remplit désormais à partir du plan :

{
  // compteurs et conflits comme ci-dessus ; chaque conflit a désormais
  //   resolution: "table" | "target" | "merged" | "custom" | "manual" | "skipped",
  //   source?: "ownership" | "column" | "resolver" | "rule", value? (merged et custom)
  overridden?: SyncPreviewConflict[]; // les premières colonnes réécrites par leur propriétaire
  overriddenCount?: number; // toutes les réécritures, par défaut overridden.length
  pendingConflicts?: number; // conflits qui attendront une personne
}

Pour les hôtes avec un écran sur mesure : resolution a de nouvelles valeurs, et un code qui les traite de façon exhaustive doit gérer merged, custom, manual et skipped. Les lignes de describeSyncPreview (SyncPreviewConflictLine) gagnent winner (le côté dont la valeur est gardée, s'il y en a un) et result (la valeur fusionnée ou choisie par l'arbitre), et la vue gagne overriddenTitle, overridden et moreOverridden. formatSyncValue(value, t, { type?, locale? }) formate une valeur selon le type de colonne.

Mettez table.sync: false dans la configuration pour ne garder que l'envoi : le choix du sens et les sources de Données › Importer disparaissent, et push fonctionne comme avant.

Les libellés peuvent être remplacés par les traductions connector.<clé>. Mettez table.connectors: false dans la configuration pour masquer les écrans.

CSV direct pour les lignes sélectionnées

Avec export: false, Exporter en masse ignore l'écran d'export et écrit directement un CSV de la sélection. Le parcours Administration permet de comparer une page sélectionnée et une sélection sur plusieurs pages.

Copiez cette configuration complète à côté de product-config.ts. Utilisez () => exampleConfig pour getTableConfig en React, ou :config="exampleConfig" en Vue.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.