FieldMapper UI
Reference for the FieldMapper React component and the useFieldMapper hook from @seedprotocol/mapping/react: every prop, the row layouts, lookup pickers, slots, theming and the deprecated props to avoid.
FieldMapper is a React component for editing a mapping: one row per model property, each with the source chosen for it, a transform (copy, extract, file, lookup) and a preview of the value. It's controlled and has no Seed dependencies: you pass the sources, targets and edges, and it calls onChange with the new edges. useFieldMapper is the same logic without the markup, for building your own UI.
import { FieldMapper, useFieldMapper } from '@seedprotocol/mapping/react'/react needs react and react-dom 18 or 19. The types it works with (SourceNode, TargetProperty, FieldMapping) are described in the Overview.
FieldMapper
function FieldMapper(props: FieldMapperProps): JSX.ElementProps
Required:
| Name | Type | Description |
|---|---|---|
sources | SourceNode[] | One sample entry, e.g. items[0] from rssXmlToSources. Pass the plain sources, not buildResolvedSourceNodes(...): transforms are picked per row |
targets | TargetProperty[] | The model's properties. Mark properties required: true to have missing ones flagged |
mappings | FieldMapping[] | The current edges |
onChange | (mappings: FieldMapping[]) => void | Called with the full new list after every edit. Store it and pass it back as mappings |
Optional:
| Name | Type | Default | Description |
|---|---|---|---|
rowKey | 'property' | 'source' | 'property' | 'property': one row per property, so a property can't have two sources. 'source': one row per edge, with banners for conflicts and missing required properties. See Row layouts |
defaultRows | 'requiredAndMapped' | 'mapped' | 'all' | 'requiredAndMapped' | Which property rows show at first in 'property' mode. The user can widen it with the filter buttons |
renderLookup | (row: FieldMapperLookupRow) => ReactNode | none | Your picker for lookup rows, such as a list of Author items. Without it, lookup edges get the built-in editor. See Lookup pickers |
renderRowAccessory | (row: FieldMapperRow) => ReactNode | none | Extra controls in each row, after the transform select and before the remove button, e.g. a Preview button on extract rows. Return null for nothing |
renderRowExpansion | (row: FieldMapperRow) => ReactNode | none | Content under a row, e.g. a block list for an assemble edge. Return null for nothing |
transformOptions | (row: FieldMapperRow, defaults: FieldMapperTransformOption[]) => FieldMapperTransformOption[] | defaults | Changes the transform list for a row. See Transforms |
onAutoMap | () => FieldMapping[] | autoMap(sources, targets, mappings) | What the Auto-map button produces. The result replaces all edges. The default passes the current edges, so a property that stays a lookup keeps its entries and split. In source mode, unfinished rows are dropped too |
slots | FieldMapperSlots | all on | Turns parts of the UI on or off. See Slots |
theme | 'default' | 'structural' | 'none' | 'default' | How much CSS the component injects. See Styling |
classNames | Partial<Record<FieldMapperSlot, string>> | none | Your classes, added next to the package's class on each part |
components | { Select?, Button?, Pill? } | native elements | Your own select, button and pill components |
className | string | none | Extra class on the root element |
The deprecated props are listed at the end.
Example
A feed mapper with an author picker on lookup rows:
import { useEffect, useState, type ReactNode } from 'react'
import {
rssXmlToSources,
autoMap,
lookupKey,
type FieldMapping,
type SourceNode,
type TargetProperty,
} from '@seedprotocol/mapping'
import { FieldMapper } from '@seedprotocol/mapping/react'
const targets: TargetProperty[] = [
{ name: 'title', dataType: 'Text', required: true },
{ name: 'body', dataType: 'Html' },
{ name: 'author', dataType: 'Relation', ref: 'Author', required: true },
]
export function MapFeed({ xml, onSave }: { xml: string; onSave: (m: FieldMapping[]) => void }) {
const [sample, setSample] = useState<SourceNode[]>([])
const [mappings, setMappings] = useState<FieldMapping[]>([])
useEffect(() => {
rssXmlToSources(xml).then(({ items }) => {
const first = items[0] ?? []
setSample(first)
setMappings(autoMap(first, targets))
})
}, [xml])
return (
<>
<FieldMapper
sources={sample}
targets={targets}
mappings={mappings}
onChange={setMappings}
theme="structural"
slots={{ preview: 'row', inspector: false }}
renderLookup={(row) => (
<AuthorPicker
name={row.sampleValue}
onPick={(seedLocalId) => {
const key = lookupKey(row.sampleValue)
const others = (row.mapping?.lookup?.entries ?? []).filter((e) => lookupKey(e.value) !== key)
row.onLookupChange([...others, { value: row.sampleValue, refs: [seedLocalId] }])
}}
/>
)}
/>
<button type="button" onClick={() => onSave(mappings)}>
Save mapping
</button>
</>
)
}
// Your own component: lists Author items and calls onPick with one's seedLocalId
declare function AuthorPicker(props: { name: string; onPick: (seedLocalId: string) => void }): ReactNodeThe example calls rssXmlToSources, so in a Vite app it needs the plugin's includeNodePolyfills, as In a browser shows.
Row layouts
rowKey="property" (the default) shows one row per property. Each row has the property name (with * when required) and type, a source select listing every source with a sample of its value, a transform select once a source is chosen, your accessory, a remove button, and a preview line. Above the rows are an Auto-map button, a coverage count ("3 of 8 properties mapped · 1 required missing") and two filter buttons, Mapped + required and All N properties. Below them are an Add mapping button, which opens a select of the properties without an edge, and a collapsible Source fields list: each of the entry's sources with its sample, the properties it feeds (or "unused"), and a Map this button that opens the same select.
Rows shown at first depend on defaultRows: 'requiredAndMapped' shows mapped and required properties, 'mapped' only mapped ones (and neither filter button starts pressed), 'all' every property. Properties added with Add mapping stay visible.
rowKey="source" shows one row per edge, in order: source select, transform select, "into", property select. The coverage count also names the missing required properties, and a banner repeats them. Rows naming a property that another row also names are all flagged, the first one included, with a banner counting them ("2 rows claim the same property"). Add mapping adds an empty row, which becomes an edge once it has both a source and a property. Use this mode when you want users to see the edge list as it is. Source mode has no filter buttons or Source fields list, and ignores defaultRows.
In both modes, choosing a source also sets the transform the row needs, if any: lookup for a relation property, extract for a URL going into an Html property, file for a URL going into an Image or File property.
Row states
Every row element carries data-state, which is also row.state in render props:
data-state | Meaning |
|---|---|
empty | No source chosen |
mapped | Ready to apply |
needsResolve | The edge lacks the transform its source and property need: lookup for any source on a relation property, extract for a URL on an Html property (assemble also counts), file for a URL on an Image or File property. The row shows "needs extract", "needs file" or "needs lookup" |
needsLookup | A lookup edge whose table has no entry for the sample entry's text, or whose sample text is empty. With split, also while any part of the split sample has no entry |
conflict | (source mode) Another row names the same property |
Rows also carry data-property-name, data-required="true", data-resolve (the edge's transform) and data-source-kind.
FieldMapperRow
The row object passed to renderRowAccessory, renderRowExpansion and transformOptions.
| Field | Type | Description |
|---|---|---|
id | string | The property name in property mode. In source mode, a generated ID (edge-…, or draft-… for a row that isn't an edge yet). Edge IDs follow positions in mappings, so removing a row shifts the IDs of the rows after it |
target | TargetProperty | undefined | The row's property |
mapping | FieldMapping | undefined | The row's edge, if any |
source | SourceNode | undefined | The chosen source |
sampleValue | string | The source's value in the sample entry after normalizeLookupKey (NFC, trimmed, whitespace collapsed); '' when the value is empty |
preview | string | What the row's preview line shows: the converted value, the looked-up IDs (with ‹lookup ref for "<part>"› for each part of a split value that has no entry), or a description such as ‹extract HTML from https://…› |
requiredResolve | ResolveJob | null | The transform this source and property need, if any |
state | FieldMapperRowState | See Row states |
Previews use the same rules as applyMapping, so a Date property previews as a millisecond timestamp.
Lookup pickers
The package doesn't know what an Author is, so renderLookup is where you supply a picker that does. It's called for rows whose edge has resolve: 'lookup', with a FieldMapperLookupRow: everything in FieldMapperRow plus
onLookupChange: (entries: LookupEntry[]) => void
onSplitChange: (separators: LookupSeparator[] | null) => voidonLookupChange replaces the edge's whole lookup.entries list (an empty list removes it) and calls onChange. Keep the entries for other names, as the example above does. Entries match by lookup key (case-insensitive, see LookupEntry), so compare with lookupKey when you drop the old entry: with e.value !== row.sampleValue, a re-pick for "mira chen" would leave a "Mira Chen" entry ahead of the new one, and the first match wins. The picker's job is to turn row.sampleValue (the sample entry's author name) into item IDs; the other entries' names show up as users map more entries, or you can offer every name in the feed with collectLookupValues.
onSplitChange sets the edge's split separators, or removes split with null or []; FieldMapper has no control of its own for it, so render one in your picker if you want joint bylines split. Code that builds a FieldMapperLookupRow itself, such as a test, has to supply both functions.
Lookup rows stay in the needsLookup state until the table has an entry for the sample's text, or with split, for every part of it.
Built-in editor
Without renderLookup, each lookup edge gets a text editor: one line per name, with an input where the user types IDs separated by commas or spaces. It lists the edge's entries and the sample entry's text, one line per lookup key. Each keystroke writes the edge's lookup.entries through onChange, and emptying an input removes that entry. It has no control for split.
slots.lookups and layout decide what shows:
slots.lookups | layout="rows" | layout="wires" |
|---|---|---|
'row' | renderLookup or the editor in each lookup row. The default | Nothing |
'section' | The editor in one Relation lookups block under the rows. With renderLookup, nothing | The editor in the Relation lookups block, or renderLookup once per lookup edge under the rows. The default |
false | Nothing | Nothing |
renderLookup isn't called with slots.lookups: 'section' in the rows layout or with slots.lookups: 'row' in the wires layout, and those rows have no editor at all.
Transforms
The transform select appears once a row has a source. By default it offers copy, extract, file and lookup, plus assemble on Html properties, plus the edge's current transform if it's something else. transformOptions receives the row and that default list and returns the list to show:
type FieldMapperTransformOption = { value: '' | ResolveJob; label: string; disabled?: boolean }value: '' means copy. To offer a slug that's derived from the title:
import { FieldMapper } from '@seedprotocol/mapping/react'
import type { FieldMapping, SourceNode, TargetProperty } from '@seedprotocol/mapping'
declare const sources: SourceNode[]
declare const targets: TargetProperty[]
declare const mappings: FieldMapping[]
declare const setMappings: (m: FieldMapping[]) => void
export const SlugAwareMapper = () => (
<FieldMapper
sources={sources}
targets={targets}
mappings={mappings}
onChange={setMappings}
transformOptions={(row, defaults) =>
row.target?.name === 'slug' ? [...defaults, { value: 'derive', label: 'from title' }] : defaults
}
/>
)Offering derive also shows the transform select on rows without a source; once such a row has a source-less derive edge, its select is disabled until the user picks a source. The package saves resolve: 'derive' on the edge but doesn't compute anything; set derive.from with useFieldMapper's setDerive or withDeriveSpec, and apply it yourself after applyMapping.
Slots
slots switches parts of the component on and off.
| Slot | Type | Default | What it controls |
|---|---|---|---|
autoMap | boolean | true | The Auto-map button |
filter | boolean | true | The filter buttons (property mode) |
inspector | boolean | true | The collapsible list of source fields (property mode) |
preview | 'row' | 'json' | 'both' | false | 'both' | 'row': a preview line under each row. 'json': a collapsible JSON preview of the whole bag, with edges that need your resolver listed under _pendingResolve. 'both': both |
lookups | 'row' | 'section' | false | 'row', or 'section' in the wires layout | Where lookup editors go. 'row': in each lookup row. 'section': in one block under the rows. false hides them, renderLookup included. See Built-in editor |
Styling
theme decides how much CSS the component brings:
theme | Injects |
|---|---|
'default' | Layout and colors: a self-contained dark theme |
'structural' | Layout and spacing only; colors come from your page |
'none' | Nothing. Style the classes yourself |
The CSS goes into one <style id="seed-field-mapper-theme"> element in document.head, added the first time a FieldMapper mounts (never during server rendering). It sits in the cascade layer @layer seed-field-mapper, so any unlayered rule of yours wins without specificity tricks. The element is shared: with two FieldMappers on one page, the last theme other than 'none' to mount or change is the one in effect, and switching to 'none' doesn't remove CSS that's already there.
Classes and CSS variables
Every part has a fixed class, and classNames adds yours next to it:
| Slot name | Package class | Slot name | Package class |
|---|---|---|---|
root | seed-field-mapper | rowAccessory | fm-row-accessory |
toolbar | fm-toolbar | preview | fm-row-preview |
filter | fm-filter | validation | fm-row-flag |
coverage | fm-meta | removeButton | fm-remove |
autoMapButton | fm-btn fm-btn-primary | addButton | fm-btn |
rowList | fm-row-list | inspector | fm-inspector |
row | fm-row | lookupEditor | fm-lookups |
propertyLabel | fm-row-prop-name | rowExpansion | fm-row-expansion |
dataType | fm-dtype | jsonPreview | fm-preview |
sourceSelect, transformSelect | fm-select |
The root also gets seed-field-mapper--rows (--wires in the wires layout), seed-field-mapper--unstyled with theme="none", and data-theme, data-layout and (rows layout only) data-row-key attributes. SLOT_BASE_CLASS exports the table above.
The injected themes read CSS variables you can override on .seed-field-mapper: colors (--sfm-color-ground, --sfm-color-ink, --sfm-color-well, --sfm-color-line, --sfm-color-muted, --sfm-color-faint, --sfm-color-selection, --sfm-color-warning, --sfm-color-danger, --sfm-color-highlight, --sfm-map-1 … --sfm-map-8) and layout (--sfm-space-1 … --sfm-space-4, --sfm-radius-sm|md|lg, --sfm-font-family, --sfm-font-mono, --sfm-font-size-sm|md, --sfm-control-height, --sfm-row-gap). 'structural' defines only the layout ones.
Your own controls
components swaps in your design system's controls. Each gets these props:
| Component | Props |
|---|---|
Select | className?, value: string, onChange: (value: string) => void, children (<option> elements), disabled?, aria-label? |
Button | className?, type?, onClick?, children, disabled?, aria-label?, aria-pressed? |
Pill | className?, children |
The Select receives native <option> children, so a custom select has to read them or render a native <select> underneath. The prop types are exported as FieldMapperSelectProps, FieldMapperButtonProps and FieldMapperPillProps.
useFieldMapper
The state logic behind FieldMapper, for a UI of your own.
function useFieldMapper(args: UseFieldMapperArgs): UseFieldMapperResult
type UseFieldMapperArgs = {
sources: SourceNode[]
targets: TargetProperty[]
mappings: FieldMapping[]
onChange: (mappings: FieldMapping[]) => void
rowKey?: 'property' | 'source'
onAutoMap?: () => FieldMapping[]
}rowKey defaults to 'property'.
It returns the rows to render and functions that edit the edges and call onChange. A rowId is a row's id: the property name in property mode.
| Field | Type | Description |
|---|---|---|
rows | FieldMapperRow[] | Property mode: one per target, unfiltered (filter with filterPropertyNames). Source mode: one per edge, then the rows addRow added that aren't edges yet |
sourceOptions | FieldMapperOption[] | Options for a source select, resolved sources left out: { value, label, sample, kind }. label is the source's label, subtitle and a short sample |
propertyOptions | FieldMapperOption[] | Options for a property select: { value, label, dataType }, with * after required names |
addableProperties | TargetProperty[] | Properties with no edge that addRow hasn't added |
coverage | { mapped: number; total: number; missingRequired: string[] } | mapped counts properties with an edge isPresentEdge accepts; total is the number of targets |
conflicts | Set<string> | Source mode: the IDs of every row whose property another row also names. Always empty in property mode |
setSource | (rowId, sourceId | null) => void | Choose or clear a row's source. Choosing one also sets the transform the row needs (see Row layouts); clearing it keeps a derive edge |
setProperty | (rowId, propertyName) => void | Change a row's property. Does nothing in property mode |
setTransform | (rowId, resolve | null) => void | Change a row's transform (null for copy), through applyResolveToEdge. Setting 'lookup' on a lookup edge keeps its entries and split |
setLookupEntries | (rowId, entries) => void | Replace a lookup edge's entries. Does nothing for a row whose edge isn't a lookup |
setLookupSplit | (rowId, separators | null) => void | Set a lookup edge's split, or remove it with null or []. Does nothing for a row whose edge isn't a lookup |
setAssembleBlocks | (rowId, blocks) => void | Set assemble.blocks. Does nothing unless the edge is an assemble edge |
setDerive | (rowId, spec | null) => void | Set or clear derive. In property mode, a spec on a row with no edge creates a source-less derive edge |
addRow | (propertyName?) => void | Property mode: takes the property out of addableProperties without creating an edge; showing its row is up to you (pass it to filterPropertyNames in extra). Source mode: adds an empty row, optionally with the property chosen |
removeRow | (rowId) => void | Remove a row and its edge |
autoMap | () => void | Replace all edges with onAutoMap() or autoMap(sources, targets, mappings), which keeps the entries and split of a property that stays a lookup |
connect and removeBySource also exist; they serve the deprecated wires layout. filterPropertyNames(targets, mappings, mode, extra) returns the property names to show for a defaultRows mode, plus the names in the extra set, or null for 'all'.
Other exports
The pure functions the component is built from are exported too, for previews or tests outside React: computeCoverage, findConflicts, computeRequiredResolve, previewForEdge, buildSyncPreviewBag (the JSON preview's content), buildSourceOptions, buildPropertyModeRows, connectSourceToProperty, defaultTransformOptions, resolveTransformOptions, showTransformSelect, isTransformSelectDisabled, resolveSlots, showRowPreview, showJsonPreview and slotClass, plus the theme pieces resolveTheme, ensureThemeInjected, buildThemeCss, wrapInThemeLayer, DEFAULT_THEME_CSS, STRUCTURAL_THEME_CSS, PAINT_THEME_CSS and THEME_STYLE_ID. resetThemeInjectionForTests is for tests only. FIELD_MAPPER_PAIR_SLOTS (8) and connectionStrokeForIndex serve the deprecated wires layout's connector colors. connectSourceToProperty(mappings, sources, targets, sourceId, propertyName) returns the next FieldMapping[].
Deprecated props
These /react APIs still work. Avoid them in new code.
| Deprecated | Instead |
|---|---|
layout="wires" | The default layout="rows". The wires layout is a two-pane UI with connector lines that expects buildResolvedSourceNodes(sources) as sources |
theme="unstyled" | theme="none", which it maps to |
showPreview | slots.preview. showPreview={false} hides both previews; slots.preview wins when both are set |
connectionColors, and the connectionStrokeForIndex helper | The --sfm-map-1 … --sfm-map-8 CSS variables (wires layout only) |
Related
- Overview: the types and the data flow
- Helpers: parsers,
autoMapand the apply functions - Import an RSS feed into your models:
FieldMapperin a full import
Overview
What @seedprotocol/mapping does, how to install it, its two entry points and the types a mapping is made of.
Helpers
Reference for the headless @seedprotocol/mapping API: the RSS and Markdown parsers, autoMap, applyMapping and applyMappingAsync, coerceValue, resolve callbacks, lookup and edge helpers, and saving a mapping as JSON.