Seed Protocol
@seedprotocol/mapping

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.Element

Props

Required:

NameTypeDescription
sourcesSourceNode[]One sample entry, e.g. items[0] from rssXmlToSources. Pass the plain sources, not buildResolvedSourceNodes(...): transforms are picked per row
targetsTargetProperty[]The model's properties. Mark properties required: true to have missing ones flagged
mappingsFieldMapping[]The current edges
onChange(mappings: FieldMapping[]) => voidCalled with the full new list after every edit. Store it and pass it back as mappings

Optional:

NameTypeDefaultDescription
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) => ReactNodenoneYour 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) => ReactNodenoneExtra 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) => ReactNodenoneContent under a row, e.g. a block list for an assemble edge. Return null for nothing
transformOptions(row: FieldMapperRow, defaults: FieldMapperTransformOption[]) => FieldMapperTransformOption[]defaultsChanges 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
slotsFieldMapperSlotsall onTurns parts of the UI on or off. See Slots
theme'default' | 'structural' | 'none''default'How much CSS the component injects. See Styling
classNamesPartial<Record<FieldMapperSlot, string>>noneYour classes, added next to the package's class on each part
components{ Select?, Button?, Pill? }native elementsYour own select, button and pill components
classNamestringnoneExtra class on the root element

The deprecated props are listed at the end.

Example

A feed mapper with an author picker on lookup rows:

src/MapFeed.tsx
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 }): ReactNode

The 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-stateMeaning
emptyNo source chosen
mappedReady to apply
needsResolveThe 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"
needsLookupA 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.

FieldTypeDescription
idstringThe 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
targetTargetProperty | undefinedThe row's property
mappingFieldMapping | undefinedThe row's edge, if any
sourceSourceNode | undefinedThe chosen source
sampleValuestringThe source's value in the sample entry after normalizeLookupKey (NFC, trimmed, whitespace collapsed); '' when the value is empty
previewstringWhat 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://…›
requiredResolveResolveJob | nullThe transform this source and property need, if any
stateFieldMapperRowStateSee 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) => void

onLookupChange 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.lookupslayout="rows"layout="wires"
'row'renderLookup or the editor in each lookup row. The defaultNothing
'section'The editor in one Relation lookups block under the rows. With renderLookup, nothingThe editor in the Relation lookups block, or renderLookup once per lookup edge under the rows. The default
falseNothingNothing

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.

SlotTypeDefaultWhat it controls
autoMapbooleantrueThe Auto-map button
filterbooleantrueThe filter buttons (property mode)
inspectorbooleantrueThe 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 layoutWhere 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:

themeInjects
'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 namePackage classSlot namePackage class
rootseed-field-mapperrowAccessoryfm-row-accessory
toolbarfm-toolbarpreviewfm-row-preview
filterfm-filtervalidationfm-row-flag
coveragefm-metaremoveButtonfm-remove
autoMapButtonfm-btn fm-btn-primaryaddButtonfm-btn
rowListfm-row-listinspectorfm-inspector
rowfm-rowlookupEditorfm-lookups
propertyLabelfm-row-prop-namerowExpansionfm-row-expansion
dataTypefm-dtypejsonPreviewfm-preview
sourceSelect, transformSelectfm-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:

ComponentProps
SelectclassName?, value: string, onChange: (value: string) => void, children (<option> elements), disabled?, aria-label?
ButtonclassName?, type?, onClick?, children, disabled?, aria-label?, aria-pressed?
PillclassName?, 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.

FieldTypeDescription
rowsFieldMapperRow[]Property mode: one per target, unfiltered (filter with filterPropertyNames). Source mode: one per edge, then the rows addRow added that aren't edges yet
sourceOptionsFieldMapperOption[]Options for a source select, resolved sources left out: { value, label, sample, kind }. label is the source's label, subtitle and a short sample
propertyOptionsFieldMapperOption[]Options for a property select: { value, label, dataType }, with * after required names
addablePropertiesTargetProperty[]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
conflictsSet<string>Source mode: the IDs of every row whose property another row also names. Always empty in property mode
setSource(rowId, sourceId | null) => voidChoose 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) => voidChange a row's property. Does nothing in property mode
setTransform(rowId, resolve | null) => voidChange a row's transform (null for copy), through applyResolveToEdge. Setting 'lookup' on a lookup edge keeps its entries and split
setLookupEntries(rowId, entries) => voidReplace a lookup edge's entries. Does nothing for a row whose edge isn't a lookup
setLookupSplit(rowId, separators | null) => voidSet 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) => voidSet assemble.blocks. Does nothing unless the edge is an assemble edge
setDerive(rowId, spec | null) => voidSet or clear derive. In property mode, a spec on a row with no edge creates a source-less derive edge
addRow(propertyName?) => voidProperty 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) => voidRemove a row and its edge
autoMap() => voidReplace 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.

DeprecatedInstead
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
showPreviewslots.preview. showPreview={false} hides both previews; slots.preview wins when both are set
connectionColors, and the connectionStrokeForIndex helperThe --sfm-map-1 … --sfm-map-8 CSS variables (wires layout only)

On this page