CSV exports
Distinguish full-result export from selected-record export.
Enable table.export to open the Export screen from the Data menu, and table.bulkExport to show Export in the bulk actions menu. With both enabled (the default), bulk Export opens the same Export screen with "Selected (n)" already chosen; with table.export: false, it writes a CSV of the selection directly. Both start from the current record data.
Configuration example
Start from the React quick start or Vue quick start. The following file extends their product catalog. Pass this configuration through getTableConfig in React or config in Vue.
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 },
},
},
});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 },
},
},
});Scope is part of the contract
A toolbar export retrieves the filtered result, including additional server pages when needed. A bulk export targets selected IDs, which may span pages. The list action must honor pagination and return accurate totals. A server-enforced page cap must not make the export look complete when rows are missing.
Customize delivery
onExport and onBulkExport let the application own delivery. For a large export, move generation into an authorized server job and report its progress. Export machine values rather than cell markup, use explicit headers, and check how your destination spreadsheet interprets untrusted text. See query integration and bulk actions.
Export screen
Export opens a screen with Format, Records, Columns, Values and a file name, then an Export button that shows a busy state while it runs:
Format — CSV; PDF opens the browser's print dialog with a paginated table whose header repeats on every page, ready to "Save as PDF"; Excel (
.xlsx) is offered only when the host providesactions.exportFile.table.exportFormatslimits which formats are offered.Records — "All in this view" exports every record matching the view's current search, filters and sort; "Selected (n)" exports only the selected rows, and only appears while rows are selected.
Columns — the visible columns in their displayed order, or every column.
Values — "As displayed" applies the same currency/number formats, date presets and option labels as the cells; "Raw" writes the stored values.
File name — defaults to
<table>-<saved view>-<YYYY-MM-DD>(the saved-view segment is omitted for the default view), with accents removed; edit it before exporting.
Bulk export opens this screen
This is how users pick what to export: select rows, then click Export in the bulk actions bar. With table.export enabled, the bulk bar's Export opens this same screen with "Selected (n)" already chosen as the Records scope — the selection is kept, and the user can still switch to "All in this view". A custom onBulkExport still takes over; when the Export screen is disabled (table.export: false), bulk export writes a CSV of the selection directly instead. See bulk actions.
Server-side files with actions.exportFile
Provide actions.exportFile(request) to build the file on the server — required for Excel, and recommended for CSV and PDF on large tables, since the server loads the records itself with no row-count limit from the browser. When exportFile is provided it handles every requested format.
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 is:
{
format: "csv" | "xlsx" | "pdf";
scope: "view" | "selection";
formatted: boolean; // the Values setting: true for "As displayed", false for "Raw"
fileName: string; // includes the extension
viewId: string | null; // null for the default view
query: {
search: string;
filters: Record<string, unknown>;
advancedFilters: Record<string, unknown>[];
advancedFilterJoin: "and" | "or";
sorting: { id: string; desc: boolean }[];
}; // the list action's query shape
columns: { id: string; header: string }[]; // chosen columns, in order
selectedRowIds: string[]; // only when scope is "selection"
}Return { url } for a download link (for example a signed URL from your storage), { blob } for a file built inline, or nothing if your endpoint delivers the file another way.
Without actions.exportFile
The browser builds the file itself: it loads the matching rows through list page by page (or from local data), or uses the current selection, then writes a UTF-8 CSV with a BOM — so it opens correctly in Excel — or opens the printable PDF page. Excel is unavailable in this case; leave "xlsx" out of table.exportFormats, or provide actions.exportFile to offer it. onExport still replaces the built-in CSV file, as before. A registry item that writes Excel files directly in the browser is planned as an optional addition.
Copy this complete configuration beside product-config.ts. Use () => exampleConfig as React’s getTableConfig, or :config="exampleConfig" in Vue.
Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";
export const exampleConfig = defineTableConfig({
...productConfig,
table: { ...productConfig.table, export: true, enableRowSelection: false },
});Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";
export const exampleConfig = defineTableConfig({
...productConfig,
table: { ...productConfig.table, export: true, enableRowSelection: false },
});Custom destinations
Declare destinations on your table actions (getTableActions in React, get-table-actions in Vue) to send the current result to a webhook, an n8n workflow, or a connector, instead of downloading a CSV. Each entry is { id, label, kind: "connect" | "share", icon?, hidden?, requiresSelection?, run(context) } ("sync" and "export" are still accepted as aliases of "connect"). A connect destination can also declare connector to open the table's send screen instead of running run. Label with just the tool name — "n8n", "Slack" — the row it opens already says what happens. Connect destinations (kind: "connect") open under the Data menu's Connect › row, which is hidden when there are none; share destinations (kind: "share") open under Share ›, after the built-in "Copy link" — without any share destinations, Share stays a direct copy-link action. Declared order is kept and the first id wins on a duplicate; hidden removes one, and requiresSelection only offers it while rows are selected.
run(context) receives:
{
tableId: string;
tableType?: string;
viewId: string | null; // null for the default view
query: {
search: string;
filters: Record<string, unknown>;
advancedFilters: Record<string, unknown>[]; // active rules only
advancedFilterJoin: "and" | "or";
sorting: { id: string; desc: boolean }[]; // the manual order sort is excluded
};
columns: { id: string; header: string; type?: string }[]; // visible columns, displayed order
selectedRowIds: string[];
url: string; // link that reopens this view
loadRows: () => Promise<Record<string, unknown>[]>; // every matching record
}query is the same shape the list action receives. Prefer server first: send query (and viewId/columns) to your webhook so the receiving workflow fetches the data from your API itself. Only call loadRows() when the destination needs rows from the browser, such as a small or local table — it loads every matching record through list, page by page.
Return { message } for a success toast (default "Sent" in React, "Envoyé" in Vue — translation key destinations.done in both editions); throw to show an error toast with the error's message. Only one destination runs at a time: the others are disabled and the running one shows a busy spinner.
Icons are a React node in React (for example <Webhook className="size-4" />) and a Vue component in Vue (for example Webhook from lucide-vue-next); omit icon to fall back to a default send icon.
n8n webhook example
Create an n8n workflow that starts with a Webhook node and copy its URL. The run function posts the query to that URL and lets the n8n workflow call back into your own API — server-side, with no row-count limit from the browser:
{
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" };
},
}In the n8n workflow, add an HTTP Request node after the Webhook trigger that calls your application's API with the received query, so the export runs entirely server-side.
Schedule a Connect destination
A Connect destination can also run on a schedule. In Data › Connect, a destination that supports scheduling shows a clock button next to its name. Clicking the name still sends the view's data now; the clock opens the schedule for the current view:
Frequency: Manual (the default), Automatic (on change), Hourly, Daily, Weekly or Monthly.
When: depending on the frequency, the minute of the hour, a time, a day of the week, or a day of the month or "Last day".
Start date (optional) and Time zone (the browser's by default).
A preview shows the next run — for example "Next: Thu 24 Sep, 09:30 (Europe/Paris)" — with daylight saving taken into account, and the last run status when the application reports one. The screen offers Save, Cancel and Run now. The table only edits the settings: your application stores them and runs the destination.
Declare schedule on a kind: "connect" destination:
{
id: "n8n",
label: "n8n",
kind: "connect",
run: async (context) => sendToN8n(context),
schedule: {
frequencies: ["manual", "daily", "weekly"], // optional, default all
load: async ({ viewId }) => fetchSchedule(viewId), // ScheduleSettings | null
save: async (settings, { viewId, query, columns }) => {
await storeSchedule(viewId, { settings, query, columns });
},
status: async ({ viewId }) => fetchLastRun(viewId), // optional
},
}load, save and status receive the same context as run (viewId, query, columns, selectedRowIds, url, loadRows), so a schedule belongs to a view: store it under viewId together with the query it should replay. status returns { lastRunAt?, lastResult?: "ok" | "error", message?, nextRunAt? }; when given, nextRunAt is shown instead of the preview computed in the browser. "Manual" is always offered, even when frequencies leaves it out.
ScheduleSettings is:
{
frequency: "manual" | "auto" | "hourly" | "daily" | "weekly" | "monthly";
minute: number; // 0–59, hourly runs
time: string; // "HH:mm", daily, weekly and monthly runs
weekday: number; // 0–6, 0 = Sunday, weekly runs
dayOfMonth: number | "last"; // 1–31 or the month's last day
startDate?: string; // "YYYY-MM-DD", in the schedule's time zone
timeZone: string; // IANA name, e.g. "Europe/Paris"
}Run the schedule on your server with the same rules as the preview: day 29, 30 or 31 falls back to the month's last day in shorter months; a time skipped by a daylight saving change moves forward by the gap; a time repeated when clocks go back runs once. Set table.schedule: false to hide scheduling while keeping the destinations. Labels can be overridden with schedule.<key> translations. Scheduling works the same way in React and Vue.
To push to Notion or Google Sheets, declare a connector so the table shows its send screen, and call the optional server connectors from your own server action or route, and from the worker that runs the schedule.
Share example
A share destination can post the view's link to a channel through your own 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" };
},
}Never put a webhook token, API key, or other secret in browser code. Call your own backend endpoint (/api/share/slack above), which holds the Slack or n8n credentials and makes the authenticated call server-side.
table.share: false hides the built-in Share link; it does not affect custom kind: "share" destinations. See Configuration for table.export, table.share, table.schedule and table.connectors, and the Actions Provider API for the full destinations field.
Connector screens
A Connect destination can declare connector instead of (or on top of) run. Its row in Data › Connect then opens a send screen owned by the table: your application only lists targets, describes their fields and pushes through its own server function. The screen works the same in React and Vue. Pair it with the server connectors to send to Google Sheets or Notion. The column mapping is the same component as the Import screen, in the other direction. A connector can also import from its target or keep both sides in sync: see Sync from the connector screen.
What the user sees
The screen has one select per choice, in this order:
Destination (or the destination's
labels.target, such as "Spreadsheet"): the target to write to, such as a spreadsheet or a Notion database, "Choose…" until one is picked. WithallowTargetInput, a field under it accepts a pasted link or id, and Use resolves it into a target. Refresh list reads the targets again, and the list can offer Create one from this table’s columns… (see Target check and Prepare).Section (or
labels.child, such as "Tab"): a second select when the target has children, such as a spreadsheet's tabs. The first one is chosen by default.Direction, only when the connector offers more than one: send to the target, import from it or keep both in sync. See Sync from the connector screen.
Columns: Visible (the view's visible columns, the default) or All, with their counts.
Send each column to: one select per column, set to the target field with the same name (accents, case, spaces,
-and_ignored, fields with a compatible type first). Each column can instead go to a New field "…" named after it, when the target accepts new fields, or be left out with Don't send. Fields the column cannot fill, or that another column already has, stay listed but disabled with the reason, and a Notion database's page title comes first.Match records by: the key field matched against each record's id. It defaults to Yayaw ID, offered even when the target does not have it yet if it accepts new fields.
Mode, shown only when the destination offers both: Update and add (upsert: rows with the same key are updated, the others added) or Replace everything (the target is cleared, then written). A one-line explanation follows the choice.
Records: All in this view, or Selected (n) while rows are selected (chosen by default then).
Target check, when something in the target may break the send: what to fix, what Prepare can fix for you and what is good to know. See Target check and Prepare.
Send stays disabled while the target check finds a blocking issue. It checks the settings first and shows the first problem inline (for example "Choose a destination." or "“Status” receives more than one column."). It then saves the settings with save and calls push. The button shows "Sending…" meanwhile. The result lists the non-zero counts ("3 created, 5 updated, 1 failed"), the first three failures, the number of warnings and a notice when the target's row limit was reached, with Done and Send again. When load returns the settings saved for the view, the screen opens with the same target, tab and mapping.
Errors appear inline under the form in the user's language instead of a toast. not_shared names the account to share with: "Share this destination with [email protected], then send again." Other codes (unauthorized, forbidden, not_found, rate_limited, api_disabled…) each have their own message. An unknown error shows its own message.
The schedule clock stays next to the destination's name, so a connector destination can also run on a schedule.
Declare a connector
{
id: "google-sheets",
label: "Google Sheets",
kind: "connect",
connector: {
labels: { target: "Spreadsheet", child: "Tab" }, // optional
targets: (context) => listTargets(), // ConnectorTarget[]
allowTargetInput: { // optional
label: "Or paste a spreadsheet link",
placeholder: "https://docs.google.com/spreadsheets/d/…",
resolve: (input, context) => resolveTarget(input), // ConnectorTarget
},
describe: ({ targetId, childId }, context) => describeTarget(targetId, childId), // ConnectorSchema
modes: ["upsert", "replace"], // optional, default ["upsert"]
load: ({ viewId }) => loadSettings(viewId), // optional: ConnectorSettings | null
save: (settings, { viewId }) => saveSettings(viewId, settings), // optional
push: (settings, context) => pushRows(settings, context), // ConnectorPushResult or { error }
help: { // optional
notShared: ({ serviceAccountEmail }) =>
`Share the spreadsheet with ${serviceAccountEmail} as an editor.`,
},
},
}Every function runs in the browser and may return a value or a promise; call your own server functions from them. targets, describe, load and save receive the destination context of the view (viewId, query, columns, selectedRowIds, url, loadRows), so settings belong to a view like a schedule.
targetsreturns{ id, label, description?, children?: { id, label }[] }[]. A target thatloadremembers buttargetsno longer lists is still offered, under its id.allowTargetInput.resolveturns the pasted text into one target, which is added to the list and selected. Throwinvalid_targetwhen the text is not usable.describereturns{ provider?, fields: { name, id?, type?, options?, index? }[], keyFields?, allowNewFields? }.id(a Notion property id) andindex(a sheet column position) let a renamed field keep its column, andprovider("notion"or"sheets") makes the target check strict.keyFieldslists the fields that may identify records (all fields by default).allowNewFieldsoffers New field choices and Yayaw ID for a target that can add fields, such as a sheet whose headers are written on the first push. An emptyfieldsis fine for a new tab.pushreceives the settings and the push context: the same context plusscope("view"or"selection"), andcolumnslimited to the columns sent, each with its tabletype. For"view",selectedRowIdsis empty; for"selection",selectedRowIdsandloadRowscover the selected records only. Sendquery,viewIdandselectedRowIdsto your server so it loads the rows itself.pushresolves to{ created, updated, skipped, failed, failures, warnings, warningCount, truncated }, the result of the server connectors, which can be returned as is. For a failure, return{ error: { code, details? } }or throw an error carryingcodeanddetails.targets,resolve,describeandloadreport failures by throwing.labelsrenames the Destination and Section selects for this destination.help.notShared(details)replaces thenot_sharedmessage.
Settings that pass the checks are saved on every send, whatever the push returns. A failed save shows its message next to the result without hiding it.
ConnectorSettings, what load returns and what save and push receive, is:
{
targetId: string;
childId?: string; // the chosen child, e.g. the tab
mode: "upsert" | "replace";
keyField: string; // e.g. "Yayaw ID"
keyFieldId?: string; // the key's Notion property id
keyFieldIndex?: number; // the key's sheet column position
mapping: {
columnId: string;
field: string | null; // null: not sent
fieldId?: string; // Notion property id, found before the name
fieldIndex?: number; // sheet column position, follows a renamed header
}[];
columns?: "visible" | "all";
}mapping has one entry per column of the view. Columns left out of the screen (hidden ones while Visible is chosen) and columns set to Don't send have field: null. run becomes optional for a destination with connector; keep it if you may set table.connectors: false, which hides the screens and makes the row run run again.
Helpers
connector-flow.ts (components/ui/yayaw-table/utils/connector-flow.ts in React, components/ui/yayaw-table-vue/connector-flow.ts in Vue) holds the flow and its types. It has no framework dependency, so your server code can import its helpers too:
defaultConnectorMapping(columns, fields, { allowNewFields?, keyField? }): the mapping the screen proposes.defaultConnectorKeyField(schema)andconnectorKeyFields(schema): the default key ("Yayaw ID" when offered) and the keys offered.resolveConnectorSettings({ columns, modes, saved, schema, target }): saved settings merged with the defaults for a target, keeping what still applies.validateConnectorSettings(settings, { schema, modes, target }): the problems that would stop a push, as{ code, columnId?, field? }[]. Check them again on the server before pushing.toConnectorMapping(settings):{ keyProperty, properties }, the mapping shape of the Notion server connector, pluskeyPropertyIdandpropertyIds(Notion) andfieldIndexesandkeyFieldIndex(sheets) when the settings saved them.describeSchemaReport,describeSchemaFixes,connectorSchemaBlocker,connectorFieldOptions,connectorNewTargetColumnsandschemaIssueMessage(issue, { t, target, columns }): the "Target check" block, the Prepare confirmation, the issue that disables Send, the mapping choices with their reasons and the columns for a new target, for a custom screen. See Target check and Prepare.describePushResult(result, t),describePushDetails(result, t, help?)andconnectorErrorMessage(code, details, t, help?): the result and error messages.connectorLabels(locale, translate?)buildst, for example to report a scheduled push in the same words.createConnectorFlowandconnectorScreenFields: the state machine and field list both editions render, for a custom screen.toSyncPreview(plan, { limit?, rowLabel? })andtoSyncRunResult(result, plan?): the sync engine's plan and result aspreviewandsyncreturn them. See Sync from the connector screen.connectorDirections(connector, syncEnabled?)andconnectorConflictRules(rules): the directions and conflict rules the screen offers.toPendingConflicts(pending, { rowLabel? }),describeConflictRules,connectorConflictRulesView,canResolveConflictsanddescribePendingConflicts: the conflicts left to a person aslistConflictsreturns them, and the "Rules set by your app" and "Conflicts to resolve" blocks for a custom screen. See Conflict rules set by your app.
DEFAULT_CONNECTOR_KEY_FIELD is "Yayaw ID".
Example: Google Sheets
This destination sends a view to a Google Sheets tab with the Google Sheets server connector. The browser code only calls server actions:
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";
// Server actions return failures as data; throwing them shows the localized message.
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: "Spreadsheet", child: "Tab" },
targets: async () => orThrow(await listSheets()),
allowTargetInput: {
label: "Or paste a spreadsheet link",
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,
}),
},
};Add googleSheetsDestination to the destinations of your table actions. In Vue, import the types from @/components/ui/yayaw-table-vue/connector-flow and @/components/ui/yayaw-table-vue/data-destinations.
The server actions check the user, use the Google Sheets module and return errors as data:
"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` keeps `details.serviceAccountEmail` for the screen's message.
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 {
// Every target lists its tabs, so the screen always sends one.
const headers = await readHeaderRow(credentials, target.targetId, target.childId ?? "");
return {
provider: "sheets" as const, // the target check knows sheets
fields: headers.map((name, index) => ({ name, index })), // the position lets a renamed header keep its column
keyFields: headers.length > 0 ? headers : undefined,
allowNewFields: true, // missing headers are added at the end
};
} 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;
// Never trust the browser: the user may push this view to this spreadsheet.
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,
// A sheet header per mapped column; `null` fields are not sent.
columns: settings.mapping.flatMap((entry) =>
entry.field ? [{ id: entry.columnId, header: entry.field }] : []
),
// Follow renamed headers by their saved position.
fieldIndexes: toConnectorMapping(settings).fieldIndexes,
keyColumnIndex: settings.keyFieldIndex,
rows: toConnectorRows(records),
});
} catch (error) {
return asFailure(error);
}
}The functions imported from @/server/app stand for your own code: authentication, the stored service account key, the spreadsheets each user added, the settings per view, and loading the view's rows on the server with the user's permissions (only selectedRowIds when given). In Vue, expose the same functions as Nuxt server routes and call them with $fetch.
For Notion, targets maps listNotionDatabases(token) to { id, label: title }, and describe maps getNotionDatabaseSchema(token, targetId).properties to { name, id, type, options }, with provider: "notion" and keyFields limited to title, text and number properties and no allowNewFields. Offer only "upsert", and push with pushRowsToNotionDatabase({ token, databaseId: settings.targetId, mapping: toConnectorMapping(settings), rows }).
To run the same push on a schedule, give the destination a schedule too: the worker reads the view's saved ConnectorSettings and calls the same server code.
Target check and Prepare
The screen checks the target before anything is sent, so a Notion database or a sheet that drifted never breaks a push or a sync halfway. The check runs when a saved destination opens and after every mapping change, from the fields describe returned, with the rules of Keeping the target healthy.
What the user sees:
Target check lists the issues under the choices, in three groups: To fix before sending, Can be fixed for you and Good to know. Each line is plain language, for example "Price: Notion property is Text, Number expected." or "“Yayaw ID” property missing: it holds each record’s id."
A blocking issue disables Send, Sync now and Preview changes, with the reason under them: "Fix this first: …". Fixable issues and warnings never block.
With
prepareTarget, fixable issues add Prepare Notion database (or Prepare sheet, or "Prepare" followed by the destination's name). It first shows what will change ("Create “Yayaw ID” (Text) for record ids", "Add 2 options to “Status”: Blocked, Review") with "These changes will be made in Notion. Nothing is deleted or renamed." and Make these changes or Cancel. After the changes, it reads the target again and says "3 changes made in Notion." or "Nothing to change: Notion was already ready."A field renamed in the target is followed, not lost: the check shows "Renamed in Notion: Price → Cost", the send keeps writing the renamed field, and Update mapping saves the new name. In a sheet, a renamed header is followed by its position.
In Send each column to, fields the column cannot fill stay listed but disabled with the reason: "Margin (Formula, read-only)", "Owner (Person, not supported yet)", "Status (Select, doesn’t fit)". A field another column already has is disabled too: "Cost (used by Price)". Defaults never pick such a field. For a Notion database, the first row is the page title, "Name (page title)", with the column that fills it.
Missing options of a Notion Status property block the send with a hint, because Notion's API cannot add them: "Status: 2 status options missing in Notion: Blocked, Review. Add them in Notion, then check again."
Under the destination list, Refresh list reads the targets again, and
help.missingTargetcan say how to make a missing target appear, for example "Share the page with your integration in Notion: ••• › Connections".With
createTarget, the list offers Create one from this table’s columns…: the person picks where to create it (Create in) and a Name, then Create. The new target is added to the list, selected and described.
Declare it:
connector: {
// targets, describe, load, save, push… as above
describe: async (ref, context) => ({
provider: "notion", // strict checks for Notion ("sheets" for Google Sheets)
fields: await describeFields(ref.targetId), // { name, id?, type?, options?, index? }[]
}),
checkSchema: (settings, context) => checkNotionTarget(settings, context.viewId), // optional: SchemaReport or { error }
prepareTarget: (fixes, settings) => prepareNotionTarget(fixes, settings), // optional: { applied } or { error }
createTarget: { // optional
parents: () => listParentPages(), // { id, label }[], e.g. from listNotionPages
create: ({ parentId, title, columns }) => createDatabase(parentId, title, columns), // ConnectorTarget or { error }
},
help: {
missingTarget: "Share the page with your integration in Notion: ••• › Connections",
},
},describegives each field its stableid(a Notion property id) or itsindex(a sheet column position), andprovider("notion"or"sheets") makes the check strict about types, options and missing fields. Withoutprovider, the check only looks at types and duplicates, and missing fields are left to the push.checkSchemareplaces the check in the browser with one on your server, for example with a fresh schema. The screen runs it after the same events, and the last one wins. The settings carry the saved names of renamed fields.prepareTargetreceives the fixes of the report and callsprepareNotionDatabaseorprepareSheeton your server. Both only add, so it is safe to run twice.createTarget.parentslists where a new target can go, andcreateTarget.createreceives the visible columns with their types and options (colors as Notion names them) and returns the new target, for example fromcreateNotionDatabase.createTarget.labelrenames "Create one from this table’s columns…".
The server side of these functions is in Host functions for the screen. Each mapping entry now saves fieldId and fieldIndex, and the settings save keyFieldId and keyFieldIndex, so pass them on: toConnectorMapping(settings) and toSyncMapping(settings, columns) already do. A scheduled run should check the target first and pause when schemaBlocksRun(report) is true: see Scheduled runs.
Sync from the connector screen
A connector does not only send. When it declares directions together with sync (and, ideally, preview), the screen can also import from the target or keep both sides in sync. It uses the two-way sync engine on your server.
Direction, rules and preview
A Direction select appears after the target and its section when the connector offers more than one direction: Send to Google Sheets (push), Import from Google Sheets (pull) or Keep both in sync (two-way), named after the chosen target. Push shows the screen described above and still calls push, with its modes and the selected records. Pull and two-way change the rest of the screen:
Import each field into (pull) or Sync each field with (two-way): one select per target field, set to the table column it fills, or Don't import / Don't sync. Each field shows its first sample value ("e.g. Done") and a badge counting the samples the chosen column cannot take ("2 won't convert"). Picking a column already used by another field frees it there. Two-way also lists the fields the sync will add to the target.
Match records by: the key field, as for a push.
When both sides changed (two-way only): This table wins, Google Sheets wins or Latest edit wins, each with a one-line explanation. Without edit times, as in a spreadsheet, the table wins.
Deleted records: what happens to a linked record deleted on one side. Only flag (the default) lists it and deletes nothing, Ignore leaves it alone, and Delete on the other side deletes it on the other side too. That last choice shows a confirmation checkbox, and Sync now stays disabled until it is checked and, when the connector can preview, until the changes were previewed.
Preview changes compares both sides without writing anything. It shows what will be created, updated and deleted In Google Sheets and In this table, the number of unchanged records, the records deleted on one side that are only flagged, the keys shared by several records (left alone), and the first conflicts: the record and column, both values and which one wins, or how your app's rules settle it. Any change to the settings clears the preview; Preview again runs it again.
Sync now (Import now for a pull) saves the settings with save, runs the sync and reloads the table. The result reads, for example, "In Google Sheets: 2 created, 1 updated · In this table: 3 created", with the failures, the flagged records, a notice when a limit was reached and the reason when the sync stopped early, then Done and Preview again. A sync always covers the whole view, never the selection.
Data › Import lists each connector that can pull as a source, "From Google Sheets", which opens this screen with the direction set to import. A connector destination's schedule runs the direction saved for the view, and its summary names it, for example "Every day at 09:00 (Europe/Paris) · Keep in sync".
Declare the directions
connector: {
// targets, describe, load, save, push… as above
directions: ["push", "pull", "two-way"], // default ["push"]
conflictRules: ["table-wins", "target-wins"], // optional, default all three
preview: (settings, context) => previewSync(settings, context.viewId), // SyncPreview or { error }
sync: (settings, context) => runSync(settings, context.viewId), // SyncRunResult or { error }
},directionslists the directions to offer, in order.pullandtwo-wayare offered only whensyncis declared; without them the screen stays a send screen.conflictRuleslimits the rules offered for two-way. Leave outlatest-winsfor a target without edit times, such as Google Sheets, where it would behave astable-wins.previewandsyncreceive the settings and the push context, withscopealways"view". They return the data below, or{ error: { code, details? } }likepush.previewis optional: without it, Preview changes is hidden and a sync that deletes only needs the confirmation, so declare it whenever you offer pull or two-way.describemay give each field a few values insample: the screen shows the first one and counts those the mapped column cannot convert.
ConnectorSettings gains three fields, always set by the screen (a direction preset from Data › Import, else the saved one while it is still offered, else push, table-wins and flag):
{
// targetId, childId, mode, keyField, mapping, columns as above
direction?: "push" | "pull" | "two-way";
conflictRule?: "table-wins" | "target-wins" | "latest-wins"; // two-way
deletePolicy?: "flag" | "ignore" | "propagate"; // pull and two-way
}For pull and two-way, mapping still has one entry per table column: field is the target field that column syncs with, or null. A pull leaves out fields the target does not have.
preview returns a SyncPreview:
{
createInTarget: number; updateInTarget: number; deleteInTarget: number;
createInTable: number; updateInTable: number; deleteInTable: number;
flagged: number; // deleted on one side, only flagged
duplicates: number; // keys shared by several records
unchanged: number;
conflicts: { rowId?, rowLabel?, columnId, tableValue, targetValue, resolution: "table" | "target" }[]; // the first ones
conflictCount?: number; // every conflict, default conflicts.length
}sync returns a SyncRunResult: { applied, failed, failures, flagged, truncated, stopped? }, where applied holds the non-zero counts written on each side (createInTarget, updateInTable…), failures the first failures ({ code, rowId?, rows? }) and stopped the reason an authorization error or a cancellation ended the run.
Run the sync on your server
preview and sync call two server functions that read both sides, plan with planSync, apply with applySyncPlan and turn the engine's output into plain data with toSyncPreview and toSyncRunResult:
"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 (the view's columns and rows, the target adapter such as createSheetSyncTarget or createNotionSyncTarget, and the stored SyncState), tableRows and syncStates stand for your own code; see Running a sync in a worker. toSyncPreview keeps the first 5 conflicts (limit) and names their records with rowLabel. toSyncRunResult counts the writes of each side and takes flagged from the plan. Check the settings again on the server, and lock the destination and view while a sync runs. A scheduled run uses the saved settings, so the schedule of a two-way connector runs a two-way sync.
Conflict rules set by your app
Your server can decide conflicts in code, per column: see Conflict rules in code. Those rules run on your server, where planSync runs; the connector declares what the screen shows, plus two optional functions for the conflicts left to a person:
connector: {
// targets, describe, push, directions, preview, sync… as above
conflicts: {
ownership: { price: "target" },
columnRules: { tags: "merge", notes: "manual" },
lock: true, // the "When both sides changed" select is read-only
allowManual: true, // the default: offer the conflicts to resolve
},
listConflicts: (settings, context) => listSyncConflicts(settings.targetId, context.viewId), // PendingConflict[] or { error }
resolveConflicts: (resolutions, settings, context) =>
resolveSyncConflicts(settings.targetId, context.viewId, resolutions), // SyncRunResult or { error }
},Declare the same ownership and columnRules your server passes to planSync: conflicts only tells the screen what to show, it decides nothing.
Rules set by your app appears under When both sides changed for a two-way sync (under Deleted records for an import, with ownership only, since only ownership applies there). It lists one sentence per rule, owned columns first: "Price: Google Sheets is the source of truth", "Tags: merged", "Notes: decided by you", "Name: this table wins". With
lock: true, a lock icon and "Your app decides conflicts; these rules can't be changed here." are shown, and the conflict rule select is disabled.Preview changes labels each conflict "Google Sheets wins", "Merged" (with the result), "Needs your decision", "Decided by your app" or "Left as is for now". Columns written back by their owner are listed under "Kept from the side that owns them (N)", each marked "Owned by Google Sheets", and a note says "N conflicts will wait for your decision."
With
listConflictsandresolveConflicts(andallowManualnotfalse), the screen loads the pending conflicts once the target is described, after each sync and after each resolution. Conflicts to resolve (N) opens the list: each conflict shows its record, its column and both values, formatted by column type (numbers and dates in the table's locale, yes/no, lists), with Keep table value and Keep Google Sheets value. Keep all table values and Keep all Google Sheets values settle every conflict at once. The table reloads after each resolution, and the row's other columns keep syncing meanwhile.
listConflicts returns PendingConflict { rowId, remoteId?, rowLabel?, columnId, tableValue, targetValue, baseValue?, detectedAt? }[]; toPendingConflicts(state.pendingConflicts, { rowLabel }) builds it from the sync state. resolveConflicts receives PendingConflictResolution { rowId, columnId, choice: "table" | "target" | { value } }[] and returns a SyncRunResult, as sync does.
SyncPreview gains these optional fields, which toSyncPreview now fills from the plan:
{
// counts and conflicts as above; each conflict now has
// resolution: "table" | "target" | "merged" | "custom" | "manual" | "skipped",
// source?: "ownership" | "column" | "resolver" | "rule", value? (merged and custom)
overridden?: SyncPreviewConflict[]; // the first columns written back by their owner
overriddenCount?: number; // every override, default overridden.length
pendingConflicts?: number; // conflicts that will wait for a person
}For hosts with a custom screen: resolution has new values, so code that switches on it exhaustively must handle merged, custom, manual and skipped. The lines of describeSyncPreview (SyncPreviewConflictLine) gain winner (the side whose value is kept, when one is) and result (the merged or custom value), and the view gains overriddenTitle, overridden and moreOverridden. formatSyncValue(value, t, { type?, locale? }) formats a value by column type.
Set table.sync: false in the configuration to keep push only: the Direction choice and the Data › Import sources disappear, and push works as before.
Labels can be overridden with connector.<key> translations. Set table.connectors: false in the configuration to hide the screens.
Direct CSV for selected records
With export: false, bulk Export skips the Export screen and writes a CSV of the selection directly. Use the Admin recipe to compare page selection and selection across matching pages.
Copy this complete configuration beside product-config.ts. Use () => exampleConfig as React’s getTableConfig, or :config="exampleConfig" in Vue.
Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";
export const exampleConfig = defineTableConfig({
...productConfig,
table: {
...productConfig.table,
enableRowSelection: true,
enableMultiRowSelection: true,
preserveSelectionOnQuery: true,
bulkExport: true,
export: false,
},
});Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";
export const exampleConfig = defineTableConfig({
...productConfig,
table: {
...productConfig.table,
enableRowSelection: true,
enableMultiRowSelection: true,
preserveSelectionOnQuery: true,
bulkExport: true,
export: false,
},
});