Yayaw
Documentation
Intégrations

Server-side & Server Actions

Utiliser les Server Actions pour list, create, update, delete et opérations bulk

Normaliser le contrat de requête

Cet exemple complet utilise le démarrage et les fichiers communs des recettes. La référence ci-dessous détaille les options et les fragments d’intégration.

list-records.ts
import {
  compatibleListParams,
  matchesContractFilter,
  normalizeFilterEnvelope,
  recordValue,
} from "@/components/ui/yayaw-table/utils/table-contracts";

export function listRecords<T extends Record<string, unknown>>(
  rows: T[],
  input: unknown
) {
  const params = compatibleListParams(recordValue(input));
  const page = Number(params.page);
  const limit = Number(params.limit);
  const search = String(params.search).toLowerCase();
  const filters = Object.entries(recordValue(params.filters));
  const advanced = normalizeFilterEnvelope(params.advancedFilters).filters;
  const matching = rows.filter((product) => {
    const row: Record<string, unknown> = product;
    const matchesSearch =
      !search ||
      ["name", "reference", "price", "stock", "status", "active", "tags"].some(
        (key) => {
          const value = product[key];
          return (
            value !== null &&
            value !== undefined &&
            String(value).toLowerCase().includes(search)
          );
        }
      );
    const matchesColumns = filters.every(([id, value]) =>
      matchesContractFilter(row[id], {
        type: typeof row[id] === "number" ? "number" : "text",
        operator: Array.isArray(value) ? "isAnyOf" : "contains",
        values: Array.isArray(value) ? value : [value],
      })
    );
    const matchesRule = (rule: Record<string, unknown>) =>
      matchesContractFilter(row[String(rule.columnId)], rule);
    const matchesAdvanced =
      advanced.length === 0 ||
      (params.advancedFilterJoin === "or"
        ? advanced.some(matchesRule)
        : advanced.every(matchesRule));
    return matchesSearch && matchesColumns && matchesAdvanced;
  });
  const sorting = Object.entries(recordValue(params.orderBy));
  matching.sort((left, right) => {
    for (const [id, direction] of sorting) {
      const a = (left as Record<string, unknown>)[id];
      const b = (right as Record<string, unknown>)[id];
      const comparison =
        typeof a === "number" && typeof b === "number"
          ? a - b
          : String(a ?? "").localeCompare(String(b ?? ""));
      if (comparison !== 0) {
        return direction === "desc" ? -comparison : comparison;
      }
    }
    return String(left.id).localeCompare(String(right.id));
  });
  return Promise.resolve({
    // Model a server response: later store mutations must not alter cached rows.
    data: structuredClone(matching.slice((page - 1) * limit, page * limit)),
    meta: {
      pageCount: Math.max(1, Math.ceil(matching.length / limit)),
      totalCount: matching.length,
    },
  });
}
list-records.ts
import {
  compatibleListParams,
  matchesContractFilter,
  normalizeFilterEnvelope,
  recordValue,
} from "@/components/ui/yayaw-table-vue/table-contracts";

export function listRecords<T extends Record<string, unknown>>(
  rows: T[],
  input: unknown
) {
  const params = compatibleListParams(recordValue(input));
  const page = Number(params.page);
  const limit = Number(params.limit);
  const search = String(params.search).toLowerCase();
  const filters = Object.entries(recordValue(params.filters));
  const advanced = normalizeFilterEnvelope(params.advancedFilters).filters;
  const matching = rows.filter((product) => {
    const row: Record<string, unknown> = product;
    const matchesSearch =
      !search ||
      ["name", "reference", "price", "stock", "status", "active", "tags"].some(
        (key) => {
          const value = product[key];
          return (
            value !== null &&
            value !== undefined &&
            String(value).toLowerCase().includes(search)
          );
        }
      );
    const matchesColumns = filters.every(([id, value]) =>
      matchesContractFilter(row[id], {
        type: typeof row[id] === "number" ? "number" : "text",
        operator: Array.isArray(value) ? "isAnyOf" : "contains",
        values: Array.isArray(value) ? value : [value],
      })
    );
    const matchesRule = (rule: Record<string, unknown>) =>
      matchesContractFilter(row[String(rule.columnId)], rule);
    const matchesAdvanced =
      advanced.length === 0 ||
      (params.advancedFilterJoin === "or"
        ? advanced.some(matchesRule)
        : advanced.every(matchesRule));
    return matchesSearch && matchesColumns && matchesAdvanced;
  });
  const sorting = Object.entries(recordValue(params.orderBy));
  matching.sort((left, right) => {
    for (const [id, direction] of sorting) {
      const a = (left as Record<string, unknown>)[id];
      const b = (right as Record<string, unknown>)[id];
      const comparison =
        typeof a === "number" && typeof b === "number"
          ? a - b
          : String(a ?? "").localeCompare(String(b ?? ""));
      if (comparison !== 0) {
        return direction === "desc" ? -comparison : comparison;
      }
    }
    return String(left.id).localeCompare(String(right.id));
  });
  return Promise.resolve({
    // Model a server response: later store mutations must not alter cached rows.
    data: structuredClone(matching.slice((page - 1) * limit, page * limit)),
    meta: {
      pageCount: Math.max(1, Math.ceil(matching.length / limit)),
      totalCount: matching.length,
    },
  });
}

Server-side & Server Actions

La table est conçue pour fonctionner avec des données server-side : tri, filtrage et pagination sont envoyés à votre backend, et les opérations CRUD/bulk s'exécutent côté serveur. Dans Next.js, l'approche recommandée pour cela est d'utiliser les Server Actions.

Comment ça marche

  1. État dans l'URL – Tri, filtres, pagination et visibilité des colonnes sont stockés dans l'URL (via Nuqs). Le client lit cet état et appelle votre couche de données avec les mêmes paramètres.

  2. getTableActions(tableType) – Vous retournez un objet dont les méthodes sont vos Server Actions (ou n'importe quelles fonctions async qui appellent votre API).

  3. list(params) – Appelée avec { filters, advancedFilters, limit, page (1-based), orderBy, search }. Votre action s'exécute côté serveur et retourne { data, meta: { pageCount, totalCount } }. Répondez à une page au-delà de la dernière (un ancien lien, des lignes supprimées depuis) avec le meta.totalCount et le meta.pageCount de la requête et aucune ligne, pas une erreur : depuis v3.9.1, la table demande alors la dernière page que donnent ces comptes (voir La page affichée).

  4. create, update, delete, duplicate, bulkDelete, bulkCopy, bulkUpdate – Même principe : la table appelle la fonction que vous fournissez ; vous l'implémentez en Server Action ou en appel API.

Donc la librairie ne récupère pas les données elle-même ; elle appelle ce que vous passez à getTableActions. Si ce sont des Server Actions, tout s'exécute côté serveur.

Exemple Next.js avec Server Actions

1. Module serveur (optionnel mais recommandé)

Conservez votre logique de données dans un module server-only (par ex. lib/products-server.ts) : list avec filtre/tri/pagination, create, update, delete, opérations bulk. Ce fichier doit être importé uniquement depuis des Server Actions ou d'autres modules serveur.

// app/example/lib/products-server.ts
import { products as initialProducts } from "../data";

const productsStore = [...initialProducts];

export async function listProducts(params: {
  page?: number;
  limit?: number;
  filters?: Record<string, unknown>;
  advancedFilters?: unknown[];
  orderBy?: Record<string, "asc" | "desc">;
  search?: string;
}) {
  const { page = 1, limit = 10, filters = {}, orderBy = {}, search = "" } = params;
  // Filter, sort, paginate productsStore...
  return { data: pageData, meta: { pageCount, totalCount } };
}

export async function createProduct(data: Record<string, unknown>) {
  // Insert into productsStore or DB
  return { success: true, data: newProduct };
}

export async function updateProduct(id: string, data: Record<string, unknown>) {
  // Update and return
  return { success: true, data: updated };
}

export async function deleteProduct(id: string) {
  return { success: true };
}

export async function bulkDeleteProducts(ids: string[]) { /* ... */ }
export async function bulkCopyProducts(ids: string[]) { /* ... */ }
export async function bulkUpdateProducts(ids: string[], updateData: unknown) { /* ... */ }

2. Fichier Server Actions

Créez un fichier avec "use server" qui ré-expose ces fonctions (ou appelle votre API). Ce sont ces fonctions que vous passez à la table.

// app/example/actions/products.ts
"use server";

import {
  listProducts as listProductsImpl,
  createProduct as createProductImpl,
  updateProduct as updateProductImpl,
  deleteProduct as deleteProductImpl,
  bulkDeleteProducts,
  bulkCopyProducts,
  bulkUpdateProducts,
} from "../lib/products-server";

export async function listProducts(params: Parameters<typeof listProductsImpl>[0]) {
  return await listProductsImpl(params);
}

export async function createProduct(data: Record<string, unknown>) {
  return await createProductImpl(data);
}

export async function updateProduct(id: string, data: Record<string, unknown>) {
  return await updateProductImpl(id, data);
}

export async function deleteProduct(id: string) {
  return await deleteProductImpl(id);
}

export async function bulkDelete(ids: string[]) {
  return await bulkDeleteProducts(ids);
}

export async function bulkCopy(ids: string[]) {
  return await bulkCopyProducts(ids);
}

export async function bulkUpdate(ids: string[], updateData: unknown) {
  return await bulkUpdateProducts(ids, updateData);
}

3. Connecter les actions à la table

Dans votre config de table, retournez ces Server Actions depuis getTableActions :

// app/example/setup/table-config.ts
import {
  listProducts,
  createProduct,
  updateProduct,
  deleteProduct,
  bulkDelete,
  bulkCopy,
  bulkUpdate,
} from "../actions/products";

export const getTableActions = (tableType: string) => {
  if (tableType === "products") {
    return {
      list: listProducts,
      create: createProduct,
      update: updateProduct,
      delete: deleteProduct,
      bulkDelete: bulkDelete,
      bulkCopy: bulkCopy,
      bulkUpdate: bulkUpdate,
    };
  }
};

bulkDelete, bulkCopy, bulkUpdate sans : est aussi un raccourci JavaScript valide quand la variable et la clé ont le même nom.

Quand la table a besoin de données ou exécute une action, elle appellera ces fonctions. Dans Next.js, elles s'exécutent côté serveur ; les paramètres et valeurs de retour sont sérialisés automatiquement.

4. Précharger la première page côté serveur

Pour les dashboards et les pages d'administration, privilégiez un Server Component pour la première lecture. La table client reçoit cette même première page via initialData, puis TanStack Query gère les refresh, retry, tri, filtres et pagination suivants avec la même Server Action list.

// app/example/products-page.tsx
import { listProducts } from "./actions/products";
import { ProductsTableClient } from "./products-table-client";

export default async function ProductsPage() {
  const initial = await listProducts({ limit: 10, page: 1 });

  return (
    <ProductsTableClient
      initialData={initial.data}
      initialPageCount={initial.meta.pageCount}
      initialRowCount={initial.meta.totalCount}
    />
  );
}
// app/example/products-table-client.tsx
"use client";

import { DataTable } from "@/components/ui/yayaw-table";
import { listProducts } from "./actions/products";
import { getTableConfig } from "./setup/table-config";

export function ProductsTableClient({
  initialData,
  initialPageCount,
  initialRowCount,
}: {
  initialData: Record<string, unknown>[];
  initialPageCount: number;
  initialRowCount: number;
}) {
  return (
    <DataTable
      getTableActions={() => ({ list: listProducts })}
      getTableConfig={getTableConfig}
      initialData={initialData}
      initialPageCount={initialPageCount}
      initialRowCount={initialRowCount}
      tableType="products"
    />
  );
}

Gardez l'autorisation, le scope tenant et le filtrage des données sensibles dans le code serveur qui appelle listProducts. Les props initial* sont seulement des lignes et compteurs sérialisés pour le premier rendu ; elles ne remplacent pas la Server Action utilisée par la table après hydratation.

Structure des paramètres list

L'action list reçoit un objet unique avec :

KeyTypeDescription
pagenumberIndex de page en base 1.
limitnumberTaille de page.
filtersRecord<string, unknown>Filtres colonnes (clé = id colonne, valeur = valeur du filtre).
advancedFiltersarrayRègles de filtre avancé (columnId, operator, values, type, isActive).
orderByRecord<string, "asc" | "desc">Tous les tris, par ordre de priorité (aussi envoyés dans le tableau sorting).
searchstringTerme de recherche global.

Retour attendu :

{ data: T[]; meta?: { pageCount?: number; totalCount?: number } }

Mode server-side (par défaut)

Yayaw Table exécute toujours filtrage, pagination et tri en mode server-side. Aucune option manual* n'est requise dans la config table.

Votre action list doit gérer search, filters, advancedFilters, orderBy, page et limit.

Application d'exemple

La démo publique sur https://yayaw.app/fr/table/example tourne en mode local, donc les modifications restent interactives sans backend. Son runtime vit dans Yayaw sous src/components/ui/catalog/yayaw-table-cms-runtime.tsx.

Pour une implémentation connectée au serveur, fournissez vos propres actions via getTableActions en suivant les contrats documentés ci-dessus.

Voir aussi :

Alias de requêtes et résultats React/Vue

Les adaptateurs de liste émettent les deux conventions afin de conserver les handlers existants :

ValeurChamps de requête
Page à partir de 1page
Taille de pagelimit, pageSize
Tri ordonnéobjet orderBy, tableau sorting
Recherchesearch, q, globalSearch
Filtres de colonneobjet filters
Règles avancées activestableau advancedFilters, `advancedFilterJoin: "and""or"`
Fenêtre (optionnelle)scope, par ex. { kind: "dateRange", field, endField?, from, to }

Une taille de page invalide utilise la valeur par défaut. L'entrée des filtres avancés accepte un tableau ou { filters, joinOperator } ; les règles inactives sont omises et OR reste OR après normalisation. Le filtrage local accepte les opérateurs React (is, isNot, isAnyOf, isNoneOf) et les anciens alias Vue (equals, notEquals, in, notIn). Le filtrage local des dates compare le jour de l'enregistrement, dans le fuseau horaire du lecteur, aux jours de la règle. Le serveur doit appliquer lui-même filtres, combinaison, permissions et tri.

Les agrégations reçoivent aussi la combinaison. Vue accepte les résultats primitifs et le format React { raw, label }. Renvoyez meta.pageCount ou meta.totalCount si le serveur plafonne la taille de page, afin que l'export complet et la sélection globale récupèrent tous les résultats.

Règles de date

Les règles de date comparent des jours entiers : leurs values sont des jours du calendrier écrits AAAA-MM-JJ, un jour, ou [premier, dernier] pour between, les deux jours inclus. C'est vrai pour les colonnes de date comme d'horodatage. Comparez un champ date au jour lui-même ; pour un horodatage, couvrez [début du jour, début du jour suivant) dans le fuseau horaire de votre choix (celui du lecteur, de votre client ou UTC).

{ columnId: "due", type: "date", operator: "between", values: ["2026-09-01", "2026-09-30"], isActive: true }

Depuis v3.8.0, tous les filtres de date écrivent des jours, en React comme en Vue : le calendrier, la puce compacte et les raccourcis Aujourd'hui, Hier, 7 derniers jours, 30 derniers jours et Ce mois-ci. list, aggregate, les exports, les URL et les vues enregistrées transportent des jours. Les versions précédentes écrivaient l'instant de minuit dans le fuseau du lecteur, par exemple 2026-09-24T22:00:00.000Z pour le 25 septembre à Paris, et les liens et vues enregistrées les gardent jusqu'à leur prochain enregistrement. Le navigateur les lit comme les jours du lecteur. Le code qui lit des règles enregistrées sans navigateur, comme une tâche planifiée ou un outil d'IA, les fait passer par normalizeDateFilterRules(rules, { timeZone }) de utils/date-filter-days.ts, avec le fuseau de la personne qui les a enregistrées. Un serveur qui sert aussi d'anciens clients continue de lire un instant comme le jour où il tombe dans le fuseau du lecteur.

Fenêtres de lignes

Les vues qui ont besoin de toutes les lignes d’une fenêtre plutôt que d’une page, comme une période de dates, les chargent avec loadScopedRows et envoient un scope. Un scope dateRange désigne des jours du calendrier local, bornes incluses ; une ligne correspond quand sa date de début (et sa date de fin éventuelle) chevauche la fenêtre. Filtrez par scope côté serveur et répondez meta: { scope: "applied" }. Les handlers qui ignorent scope continuent de fonctionner : la table filtre alors chaque page elle-même, ce qui transfère plus de données. Les résultats sont plafonnés (2 000 lignes par défaut) et indiquent s’ils ont été tronqués.

Ordre manuel par vue

Avec table.manualOrder, une requête de liste triée par l’identifiant __manual contient aussi viewId (null pour la vue par défaut). Enregistrez un ordre par vue et par table, et triez selon celui-ci. Les déplacements arrivent par :

reorder: async ({ viewId, id, previousId, nextId }, context) => {
  // Placer `id` entre `previousId` et `nextId` dans l’ordre de cette vue.
  return { success: true };
}

previousId et nextId sont les nouveaux voisins de l’enregistrement ; l’un des deux est absent en début ou en fin de liste. Vérifiez que l’utilisateur peut modifier la vue, et renvoyez { success: false, error } pour refuser le déplacement.

Ordre des vues

views.setOrder({ tableId, tableType, viewIds }) reçoit l’ordre complet des vues enregistrées d’une personne, du premier au dernier, sans les vues système ni la vue par défaut. Enregistrez-le tel quel, par utilisateur, organisation, type de table et identifiant de table, et renvoyez-le depuis list dans order : la table place d’abord les vues système et la vue par défaut, et en dernier les vues que l’ordre ne nomme pas, donc votre serveur n’a rien à trier. C’est une préférence personnelle, comme le favori : vérifiez le même accès que pour lister les vues, ne gardez si vous le souhaitez que les identifiants des vues que la personne voit, et ne lui faites jamais rien accorder. orderViews(views, order), dans utils/view-order.ts (utilisable côté serveur, dans les deux éditions), trie les vues comme la table quand votre serveur en a besoin. Voir Ordre des vues.

Modifications groupées de tags

Avec tags: { bulk: "patch" } sur une colonne de tags, Ajouter des étiquettes et Retirer des étiquettes appellent une seule fois bulkUpdate(ids, { [field]: { add, remove } }) pour toute la sélection. Appliquez-le à la liste actuelle de chaque enregistrement avec applyTagPatch(current, patch), dans utils/tag-catalog.ts (utilisable côté serveur, dans les deux éditions), qui retire les tags de remove, ajoute à la fin ceux de add qui manquent et garde l’ordre de la liste, dans le cadre de vos propres contrôles d’autorisation et d’écriture. Sans bulk: "patch", bulkUpdate reçoit les listes obtenues de chaque groupe de lignes, que tout serveur peut enregistrer telles quelles.