Import and export screens
@aglyn/aglyn-transfer-ui is the import wizard and the export dialog every
surface uses. Your plugin brings what is particular to its records — the
fields, the keys that find an existing record, the rules a file may not
break — and the kit does the rest the same way for every resource.
What a person sees
- Export offers every field, grouped and searchable. Presets fill the picker: Re-importable (the default — the Aglyn ID and your match keys first, so the file comes back in and finds its records), Everything, Minimal, and presets the person saves. They choose the records (the selection, the current filter, or all), the format (CSV, JSON, NDJSON) and the column order; the choice is remembered.
- Import is eight steps: Upload, Columns, Values, Matching, Conflicts, Review, Import and Results. Each column, unknown list value, unresolved reference, match and conflict is shown with a choice. The Review step is a dry run: counts, a before → after table, and every class of warning, each needing its own "I understand" before Import enables. Importing pauses and resumes; an applied import can be undone for seven days, and a record edited since is asked about rather than overwritten.
Open it
Your plugin never renders the screens itself: the console does, and hands
your plugin a launcher. Ask for it with useTransferLauncher() from
@aglyn/aglyn and open the wizard or the dialog on the resource you
declared in transferResources:
import { useTransferLauncher } from '@aglyn/aglyn'
function ItemsHeaderActions({ hostId, selectedIds, currentFilter }) {
const transfer = useTransferLauncher()
// Outside the console there is no launcher: show no Import or Export.
if (!transfer) return null
const target = { resource: 'my-plugin.items', scope: 'host', hostId } as const
return (
<>
{transfer.can('import', target) ? (
<Button onClick={() => transfer.openImport({ ...target, mappingZone: 'items' })}>Import</Button>
) : null}
{transfer.can('export', target) ? (
<Button
onClick={() =>
transfer.openExport({
...target,
selection: selectedIds,
filter: { label: 'Status is open', value: currentFilter },
})
}
>
Export
</Button>
) : null}
</>
)
}
Show only what the person may do
transfer.can('import' | 'export', { resource, scope, hostId? }) answers
what the transfer routes would: synchronously, from the permissions the
console already holds, with no request per button. It reads false until
those permissions have loaded, so a button appears once rather than
flickering away.
-
Import needs the Manage data permission — on the named site for a site's records, on the workspace otherwise.
-
Export asks what your resource declares beside its key in
transferResources:"readableByMembers": true— every member may read these records (the rules let them), so any member may export what they can see, and a site collaborator only on a site they reach. YourreadPageandcountkeep a scoped reader to their own records throughscopeTokens."readPermission": "<key>"— whoever holds that permission (a key your plugin declared, say) may export, beside Manage data.- Neither — Manage data, the same as importing. This is the default because a resource's records may be ones only managers read.
-
The plan comes before either, when your resource declares a
"featureFlag"(the flag your console surfaces are gated by): on a plan without it,cananswersfalsefor every intent your resource doesn't list in"featureFlagExempt", and the routes refuse with 403plan_required. RegisterplanGatewith the server half so the refusal is in your plugin's own words. The CRM declares"featureFlag": "crm"on every resource and exempts only the contacts and leads exports. Declare the same flag your console extension (or the section that holds the surface) is gated by, so a transfer refuses exactly what your pages refuse. Without aplanGate, a feature no plan carries is refused as the add-on it is. -
Your plugin must run where the records are: switched on for the workspace, or for the site a site resource names, and released to the workspace. Otherwise every route answers 404, as your own routes do behind the plugin dispatcher — except an export your resource keeps open on every plan (
"featureFlagExempt": ["export"]), which a workspace is owed whether or not your plugin is on. -
A role, when your resource is stricter than Manage data: declare
"importRoles"(say["admin"]) and only a member in one of those roles where the records are may import them. Don't check the role in your own hook for the button —can('import')already answers it.
Ask can for every Import and Export button you draw, and draw neither
when it answers false. The routes stay the enforcement: can only keeps
you from offering a button they would refuse.
jobIdonopenImportresumes an import where the person left it; the draft of every choice is saved per job. PutTransferResumeImport(from@aglyn/aglyn/app-utils/transfer-resume-import) beside your Import button with the same target, title andonFinished: it shows Resume import only when the person has an import of that resource they left before anything was written, and lets them reopen or discard it. The launcher'sunfinished(target)anddiscard(jobId)are what it reads, if you need them directly.mappingZonedraws theimportMappingzone under the Columns step, so an assistant plugin can propose a mapping from the headers and the shape of each column. It never sees a cell.onFinishedis called when the person leaves the wizard from its results.titlenames what is being moved ("Import into Products") where the resource's label alone would not.
One instance at a time
A resource whose records come in separate sets — a dataset's records, one
dataset at a time — is declared once with instances: true and opened on
the key that names the set, <key>:<instance>:
transfer.openImport({ resource: `data.dataset:${datasetId}`, scope: 'org', title: `Import into ${name}` })
The job, the person's remembered export fields and the one-running-import
rule are then each kept per set. Your server half reads which set from
transferResourceInstanceOf(ctx) and answers its fields and match keys for
it: matchKeys(ctx) may return { keys, defaults }, the keys that set
offers and the ones a person starts with.
Add a step of your own
A step goes after any step before Review. Register it with your resource's
client half, from your console registrar. Its component is handed the job
and its answer; what it passes to setValue is sent to the server with the
dry run under the step's id in extras, where your server half checks it,
and Next waits for setComplete(true).
The server half reads the answers as ctx.extras: in your plan hook,
those sent with this dry run; in apply and after, those the plan was made
with. They are what the person said in the browser, so check every value,
and never let one say WHO said it — the email plugin's statement of
permission records its attester from the session that made the dry run, on
its own ledger, and every write reads it from there. ctx.headers holds the
file's column names, mapped or not.
registerPluginTransferResourceUi('my-plugin.items', {
label: 'Items',
extraSteps: [{ id: 'consent', label: 'Consent', after: 'conflicts', component: ConsentStep }],
})
function ConsentStep({ value, setValue, setComplete }: TransferWizardStepProps) {
return (
<Checkbox
checked={value === true}
onChange={(event) => {
setValue(event.target.checked)
setComplete(event.target.checked)
}}
/>
)
}
Warn about what only you can see
The core warns about what it can see — a guessed date, a value to be
replaced, a row repeated. What only your records' rules can see — the email
plugin's shared mailboxes and column names that read as a bought list — is
the screening warning class: your plan hook adds it to the plan's
warnings with a detail on each sample, and the person acknowledges it like
every other class before Import enables. A sample about the whole file (a
column's name) has the row TRANSFER_FILE_SAMPLE_ROW and is shown without
one.
Lock a rule
A rule your records keep whatever a file says — a stage never moves
backward, consent is never set from a file — is a TransferLockedRule your
server half returns with the fields. The Conflicts step shows the field
disabled with your reason, and the Review step counts the values it held
back.
Resolve a column that names another record
A field of type lookup names a record of another resource — a contact's
company, a deal's contact, a task's owner — through lookup: { resource, by, creatable }. The import resolves each distinct value the file holds
through the TARGET's lookup hook: first by Aglyn ID when the cell could
be one, then by each by field in order, using the target's match-key
normalizer for that field. A value that names exactly one record becomes
that record's id. The rest are listed on the Values step with the records
they may mean — the records a value named several of, then whatever the
target's optional suggest(ctx, { by, values }) hook offers — and the
person chooses for each: create it (only when creatable), use one of
them, leave the field blank (nothing is written; a blank chosen here never
clears a value), or refuse the rows.
A field of type list with the same lookup holds several records — a
dataset's multi-reference is one. Each item is resolved and decided on its
own; your apply receives the list of ids, with an item left blank dropped
and a transferLookupNewValue(name) for each item to create.
What your apply receives for the field is the record id, or — for a value
the person chose to create — transferLookupNewValue(name), which
transferLookupNewName reads back. Create that record on your own write
path, once however many rows name it, and find the one an earlier attempt
created when a chunk is retried.
A target no resource moves — the workspace's members an owner column names
— is answered by the resource itself: list it under lookupTargets with
its own lookup (and suggest, and the matchKeys its fields compare
with), keyed by the name the field's lookup.resource uses.
A resource of the WORKSPACE (scope: 'org') may still be opened with a
hostId: the job keeps that site, your hooks get it as ctx.hostId, and
the transfer routes check the person's permission on that site — how a
workspace's records are read through one site's view and imported as that
site's captures.
Offer another product's layout
A preset of your own is listed in the export dialog after the built-in ones.
Return presets with your server half: an id that is not a built-in
preset's, a label, the fieldIds in order and, for a layout another
product imports, headers — that product's column name for each field. The
CSV is then written under those names while the preset is chosen as it
stands. Put the same names in an aliases dictionary with that product as
its source, so its own export maps column for column on import.
registerPluginTransferResource('my-plugin.items', {
// fields, matchKeys, readPage, lookup, apply, revert…
aliases: [{ source: 'Other Shop', aliases: { title: ['Item Title'], sku: ['Item SKU'] } }],
presets: [
{
id: 'other-shop',
label: 'Other Shop',
description: 'The CSV Other Shop imports.',
fieldIds: ['title', 'sku'],
headers: { title: 'Item Title', sku: 'Item SKU' },
},
],
})
The commerce plugin's products resource offers a Shopify preset this way.
When a record is several rows
A file can describe one record over several rows. A storefront export writes
a row per product variant, for example. Give your server half a plan of its
own: it receives every row of the file at once, so it can fold the rows that
share a key into one record and still give each row its own verdict and diff.
Anything the apply needs beyond the diff can ride on the planned row, because
the stored dry run keeps it. The commerce products resource folds rows by
handle this way, and matches each row's variant by SKU, then by its option
values.
A resource that only exports
Declare it with kinds: ['records'] like any other. Make every field
readOnly, and return an invariants entry that refuses any row that would
write. The dry run then fails such a row with your reason. Leave the Import
button off your surface; the commerce orders resource works this way.
A record that must come into being through your own path, never as a written
value, imports by calling that path once per row. The commerce gift card
import, for example, issues each card through the plugin's issue function.
Its plan decides each row's card and stamps it on the row, an invariants
entry fails the rows it refuses, and a wizard step of its own reads back the
total for the server to check from ctx.extras at both the dry run and Apply.
A resource that is only exported
Declare "exportOnly": true beside the resource in transferResources for
records a file never writes — a log of work done, a setup edited on its own
page. Register only the reads (fields, readPage, lookup, and count);
the upload refuses the resource, and your surface offers Export alone.
What an export asks of your server half
The export reads your records through your readPage, a page at a time,
holding only the fields the person chose. Honor every option it passes:
ids (the selection), filter (the list's filter, in your own terms) and
scopeTokens — present when the reader is a collaborator scoped to some
sites, and the only thing standing between them and the rest of the
workspace, because the export reads past the rules. Register count too
if you can: the file then carries its row count and the download is
checked whole however large it is.
Move things a person builds as a package
Records are rows. What a person builds — a sequence, a campaign, an
automation, an email template — moves as a workspace package instead: one
JSON file that can carry several resources' items, imported from
Settings → Import & export. Declare the resource with
"kinds": ["package"] and "formats": ["json"], and register the package
hooks (see the
plugin manager API). An item's kind
is your resource key.
registerPluginTransferResource('acme.playbooks', {
items: async (ctx) => existingPackageItemsOf('acme.playbooks', await readAll(ctx)),
readItems: async (ctx, ids) => readAll(ctx, ids),
dependencies: (item) => [{ kind: 'site', id: item.hostId }, { kind: 'crm.email-templates', id: item.templateId }],
remapIds: (item, idMap) => ({
...item,
hostId: remapPackageReference(idMap, 'site', item.hostId),
templateId: remapPackageReference(idMap, 'crm.email-templates', item.templateId),
}),
problems: async (ctx, write) => validate(write.content),
writeItems: async (ctx, items, writer) => writeThroughYourOwnSave(ctx, items, writer),
revertItems: async (ctx, steps) => deleteOrRestoreThroughYourOwnPaths(ctx, steps),
rules: [{ id: 'draft', label: 'Imports arrive as drafts', reason: 'Nothing runs until someone turns it on.' }],
}, { pluginId: 'acme' })
- Hash what you export.
itemshashes the same contentreadItemsanswers, throughexistingPackageItemsOf. Leave out what an import never writes — status, counters, who and when — or every round trip reads as changed. - Name every reference. Another package item is resolved by its
resource. A
siteis resolved by the engine. For any other kind (a mailbox, a list), answerreferenceTargetswith what the workspace holds, so the person can map a missing one. A reference the person drops reachesremapIdsas'', andremapPackageReferenceturns it intonull. - Write through your own save, under the
targetIdyou are handed: the package's id for a new item, yours for a replace, a fresh one for a kept-both copy (which also carriesrename). Mark every item withwriter.markApplied, a failed one too. The engine keeps the ledger and takes the undo snapshot before the first write. - Never start anything. An imported item that could act on its own —
send, run, publish — lands off, and you say so in
rules.
Columns that follow the records
When the columns depend on which records are read — one form's questions —
your fields hook is handed the filter the export dialog was opened on as
ctx.filter, and answers for it. Form submissions, which are only exported
("exportOnly": true), open on one form with that form's questions.
Flag rows with your own rules
Some rules are about a whole row, or about other rows: a redirect that loops
back through another, a destination off the site, a value your write path
refuses. Answer them from a plan hook: build the plan, then fold your
findings in. Each one is shown in the review with its sentence and must be
acknowledged before Import enables; refuse fails the row instead of only
flagging it.
import { buildTransferPlan, withTransferResourceFindings } from '@aglyn/aglyn/data-transfer'
plan(ctx, input) {
const findings = input.rows.flatMap((row) =>
isLoop(row.values) ? [{ row: row.index, detail: 'Loops back to itself', refuse: true }] : [],
)
return withTransferResourceFindings(buildTransferPlan(input), findings)
}
Match on two fields at once
A match key can be made of several fields: a calendar event is found by its
title and its start together, so a weekly class is several events rather
than one. Name the other fields in with; instant compares moments to the
minute whether the file holds text and the record milliseconds:
matchKeys: [
{ fieldId: 'id', normalizer: 'aglynId' },
{ fieldId: 'title', normalizer: 'name', with: [{ fieldId: 'startsAt', normalizer: 'instant' }] },
]
Try it without a server
The console's own specs drive the wizard with createMemoryTransferClient,
which runs the whole job in memory over the records it is given, so every
step can be walked before a resource's server half exists.