{
  "id": "BE05",
  "title": "Import wizard",
  "topic": "bulk-editing",
  "use": "Create records from a file someone else made. The file comes first, then the mapping of its columns, then the preview with the rows that do not fit, and only at the end the import. Whoever gets a file of the same layout every time needs no mapping — an upload with a result report is enough.",
  "decisions": [
    "The import is an ed-wizard of four steps in a fixed order — File, Mapping, Preview, Import: the records are not typed but taken over, so the work shifts from filling in to checking — map the columns, find the faulty rows, confirm the scope — and each step settles what the next one works on.",
    "Nothing is written before the last step: the preview shows what will arrive and names the rows that will not go through, because what slips through here stands in the register afterwards.",
    "The wizard is the screen, not a dialog over a list — a departure from the wizard's preferred home: the preview is a table of every row in the file and needs the room.",
    "The columns it recognizes are preset, and the hint marks each as a suggestion until the user chooses by hand: a suggestion that looks like a choice is taken over without a look. The hint carries it rather than the select's AI state, a departure from the select's rules, because a header match is no model's guess.",
    "A column nobody recognizes starts at Do not import: a guessed field writes a wrong value into every row, while leaving the column out loses nothing a choice cannot add back.",
    "The preview counts the rows, those it imports and those with errors, gives every row its finding — a badge for a row with an error, plain text for one that goes in, because the badge marks the exception and not the norm — and offers the rows with errors as a list to download with their reasons: the user sees what arrives before answering for it, and whoever corrects the file needs the rows, not a number.",
    "Rows with errors are skipped, not the whole import stopped, and the screen says so before the run and during it: a partial import needs a rule the user understands.",
    "The button that starts the run names what it does — Import 244 rows, not Finish — so own buttons in the wizard's nav slot replace its built-in ones: the number is the scope the user confirms.",
    "After the run, the way to the result stands where that button was — Show the 244 employees — not only a message that it worked: the next question is whether the employees have arrived as they should.",
    "Once the run has started, Back is unavailable and the footer says why — a departure from the wizard's rule never to block it: the rows are written, and going back would offer them a second time."
  ],
  "try": [
    {
      "do": "Look at the File step",
      "then": "the file is chosen and shows its name and size, the delimiter is read from it, and the chain above names all four steps"
    },
    {
      "do": "Choose Next",
      "then": "every column of the file has a field, preset from its header and marked as a suggestion; Remark, which nobody recognizes, starts at Do not import"
    },
    {
      "do": "Choose Last name for column F",
      "then": "its hint is gone, as the value now comes by hand; both columns say they share the field, and the footer says why Next waits"
    },
    {
      "do": "Set column F back to Do not import and choose Next",
      "then": "the preview counts 248 rows — 244 to import, and 4 with errors that are skipped — and every row says what becomes of it"
    },
    {
      "do": "Choose Download error list",
      "then": "the four rows come as a file, each with its row number and its reason"
    },
    {
      "do": "Choose Back twice",
      "then": "the mapping and the file are as you left them — the preview is step three, and File and Mapping lie before it"
    },
    {
      "do": "Go on to the preview and choose Import 244 rows",
      "then": "the last step says that the rows with errors stay in the error list, the bar runs, and at the end Show the 244 employees stands where the button was; Back is unavailable now, and the footer says why"
    }
  ],
  "revision": 1,
  "changes": [],
  "reviewed": false,
  "source": "<!-- Demo only, not part of the pattern. The preview table takes the height the screen leaves it; here it is set to fit the frame. -->\n\n<ed-wizard id=\"be05-wizard\" label=\"Import employees\" active=\"file\" linear>\n  <ed-wizard-step name=\"file\" label=\"File\">\n    <div class=\"ed-stack ed-gap-4\">\n      <h3>File</h3>\n      <ed-file-input id=\"be05-file\" label=\"File\" accept=\".csv\" hint=\"A CSV file with the column names in its first row.\"></ed-file-input>\n      <ed-input id=\"be05-delimiter\" label=\"Delimiter\" readonly hidden></ed-input>\n    </div>\n  </ed-wizard-step>\n\n  <ed-wizard-step name=\"mapping\" label=\"Mapping\">\n    <div class=\"ed-stack ed-gap-4\">\n      <h3>Column mapping</h3>\n      <div id=\"be05-mapping\" class=\"ed-grid ed-gap-4\" style=\"--ed-grid-min-column-size: 12rem\"></div>\n    </div>\n  </ed-wizard-step>\n\n  <ed-wizard-step name=\"preview\" label=\"Preview\">\n    <div class=\"ed-stack ed-gap-4\">\n      <h3>Preview</h3>\n      <p id=\"be05-summary\"></p>\n      <ed-data-grid id=\"be05-preview\" label=\"Preview\" row-key=\"row\" hide-column-menu data-ed-preload=\"ed-format-date\"></ed-data-grid>\n    </div>\n  </ed-wizard-step>\n\n  <ed-wizard-step name=\"import\" label=\"Import\">\n    <div class=\"ed-stack ed-gap-4\">\n      <h3>Import</h3>\n      <p id=\"be05-outcome\" role=\"status\"></p>\n      <ed-progress-bar id=\"be05-progress\"></ed-progress-bar>\n    </div>\n  </ed-wizard-step>\n\n  <div slot=\"nav\" class=\"ed-cluster ed-gap-3 ed-align-items-center\">\n    <!-- The stepper says the same to assistive technology. -->\n    <span id=\"be05-position\" class=\"ed-caption-s ed-text-tabular\" aria-hidden=\"true\"></span>\n    <span id=\"be05-reason\" class=\"ed-caption-s\"></span>\n    <ed-button id=\"be05-back\" class=\"ed-ml-auto\" appearance=\"outlined\">Back</ed-button>\n    <ed-button id=\"be05-next\">Next</ed-button>\n    <ed-button id=\"be05-result\" hidden></ed-button>\n    <ed-button id=\"be05-errors\" appearance=\"plain\" hidden>\n      <ed-icon slot=\"start\" name=\"export\"></ed-icon>\n      Download error list\n    </ed-button>\n  </div>\n</ed-wizard>\n\n<!-- Demo only, not part of the pattern. The product supplies employeeFields, the fields an employee takes from a file, as { key, label, required, headers } with the keys pnr, lastName, firstName, start and costCenter, and headers the column names recognized as that field; fileAtStart, the file chosen when the screen opens — none, unless the product carries one over from where the user came; readFile(file), which resolves to { delimiter, headers, rows } with the first line's column names and every other line as a list of strings; checkRecords(records), which takes one record per row — its line in the file as row, the mapped values under the fields' keys — and resolves to the records as they would be stored, the start date as YYYY-MM-DD where it is one, each with finding: empty when it can be created, else the reason; and importEmployees(records, onProgress), which creates the records, calls onProgress with the number done after each one, and resolves to the address of the employee list that shows them. What the screen does when reading or storing fails is not part of this pattern. -->\n\n<script type=\"module\">\n  const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;\n  const element = (tag, props) => Object.assign(document.createElement(tag), props);\n  const day = value => element('ed-format-date', { date: value, month: 'short', day: 'numeric', year: 'numeric' });\n  const delimiters = { ';': 'Semicolon', ',': 'Comma' };\n  // Column letters as a spreadsheet names them: A to Z, then AA.\n  const columnName = index => (index >= 26 ? columnName(Math.floor(index / 26) - 1) : '') + String.fromCharCode(65 + (index % 26));\n  // A cell holding the delimiter, a quote or a line break is quoted, as a spreadsheet writes it.\n  const cell = (value, delimiter) => (/[\"\\r\\n]/.test(value) || value.includes(delimiter) ? `\"${value.replaceAll('\"', '\"\"')}\"` : value);\n  // Spreadsheet programs read a CSV file as UTF-8 only behind a byte order mark.\n  const byteOrderMark = String.fromCharCode(0xfeff);\n\n  const wizard = document.getElementById('be05-wizard');\n  const steps = Object.fromEntries([...wizard.querySelectorAll('ed-wizard-step')].map(step => [step.getAttribute('name'), step]));\n  const order = Object.keys(steps);\n  const fileInput = document.getElementById('be05-file');\n  const delimiterField = document.getElementById('be05-delimiter');\n  const mapping = document.getElementById('be05-mapping');\n  const summary = document.getElementById('be05-summary');\n  const preview = document.getElementById('be05-preview');\n  const outcome = document.getElementById('be05-outcome');\n  const progress = document.getElementById('be05-progress');\n  const position = document.getElementById('be05-position');\n  const reason = document.getElementById('be05-reason');\n  const back = document.getElementById('be05-back');\n  const next = document.getElementById('be05-next');\n  const result = document.getElementById('be05-result');\n  const errorList = document.getElementById('be05-errors');\n\n  preview.columns = [\n    { field: 'row', label: 'Row', rowheader: true, pinned: 'left', width: 64, align: 'end' },\n    { field: 'pnr', label: 'Personnel no.', width: 120, formatter: value => value || '—' },\n    { id: 'name', label: 'Name', flex: 1, minWidth: 128, value: record => [record.firstName, record.lastName].filter(Boolean).join(' ') },\n    { field: 'start', label: 'Start date', width: 112, formatter: value => (/^\\d{4}-\\d{2}-\\d{2}$/.test(value ?? '') ? day(value) : value ?? '') },\n    { field: 'costCenter', label: 'Cost center', width: 104 },\n    {\n      field: 'finding',\n      label: 'Finding',\n      flex: 1,\n      minWidth: 176,\n      pinned: 'right',\n      // The badge marks the exception; a row that goes in says so in plain text.\n      formatter: value => (value ? element('ed-badge', { variant: 'danger', textContent: value }) : 'Will be created')\n    }\n  ];\n\n  // The file as read, the column selects, the records with their findings, and the list of the failed ones.\n  let source = null;\n  let selects = [];\n  let checked = [];\n  let errorFile = null;\n  // Every change to the file or the mapping, and every step back, counts up, so a slow answer to an earlier\n  // state is dropped.\n  let generation = 0;\n  let running = false;\n  let finished = false;\n\n  const failed = () => checked.filter(record => record.finding);\n  const importable = () => checked.filter(record => !record.finding);\n  const activeStep = () => wizard.active || order[0];\n\n  function option(value, text) {\n    const entry = element('ed-option', { textContent: text });\n    entry.setAttribute('value', value);\n    return entry;\n  }\n\n  // What keeps a step from going on, in the words the footer shows; empty when nothing does.\n  function problem(name) {\n    if (name === 'file') return source ? '' : 'Choose a file.';\n    if (name === 'mapping') {\n      const chosen = selects.map(select => select.value).filter(key => key !== 'skip');\n      const twice = employeeFields.find(field => chosen.filter(key => key === field.key).length > 1);\n      if (twice) return `${twice.label} is chosen for more than one column.`;\n      const missing = employeeFields.find(field => field.required && !chosen.includes(field.key));\n      return missing ? `Choose a column for ${missing.label}.` : '';\n    }\n    if (name === 'preview') return importable().length ? '' : 'No row can be imported.';\n    return '';\n  }\n\n  function updateNav() {\n    const index = order.indexOf(activeStep());\n    const why = problem(activeStep());\n    const backFocused = document.activeElement === back;\n    position.textContent = `Step ${index + 1} of ${order.length}`;\n    reason.textContent =\n      why ||\n      (running\n        ? 'Back is unavailable while the import runs.'\n        : finished\n          ? 'Back stays unavailable: the employees are imported, and going back would offer the same rows a second time.'\n          : '');\n    back.disabled = index === 0 || running || finished;\n    // A disabled button drops its focus to the page, so it moves on to Next first.\n    if (back.disabled && backFocused) next.focus();\n    next.hidden = finished;\n    next.disabled = Boolean(why);\n    next.textContent = index < order.indexOf('preview') ? 'Next' : `Import ${count(importable().length, 'row')}`;\n    result.hidden = !finished;\n    errorList.hidden = index < order.indexOf('preview') || failed().length === 0;\n  }\n\n  // The wizard learns of a completed step in its next update; until then it refuses to move on.\n  async function complete(step) {\n    step.completed = true;\n    await step.updateComplete;\n    await wizard.updateComplete;\n  }\n\n  function buildMapping() {\n    const taken = new Set();\n    selects = source.headers.map((header, index) => {\n      const name = header.trim().toLowerCase();\n      const field = employeeFields.find(\n        candidate => !taken.has(candidate.key) && [candidate.label, ...candidate.headers].some(known => known.toLowerCase() === name)\n      );\n      if (field) taken.add(field.key);\n      const select = element('ed-select', {\n        label: `Column ${columnName(index)} · ${header}`,\n        hint: field ? 'Suggestion · recognized from the header' : 'Not recognized'\n      });\n      select.dataset.column = columnName(index);\n      select.append(option('skip', 'Do not import'), ...employeeFields.map(candidate => option(candidate.key, candidate.label)));\n      select.value = field?.key ?? 'skip';\n      // The mark goes once the value comes by hand.\n      select.addEventListener('change', () => {\n        select.hint = '';\n        changeMapping();\n      });\n      return select;\n    });\n    mapping.replaceChildren(...selects);\n  }\n\n  // Drops whatever answer is still on its way, and the spinner that waits for it.\n  function invalidate() {\n    generation += 1;\n    next.loading = false;\n    return generation;\n  }\n\n  function changeMapping() {\n    invalidate();\n    for (const select of selects) {\n      const others = selects.filter(other => other !== select && select.value !== 'skip' && other.value === select.value);\n      select.error = others.length ? `Also chosen for column ${others.map(other => other.dataset.column).join(', ')}.` : null;\n    }\n    steps.mapping.completed = false;\n    steps.preview.completed = false;\n    updateNav();\n  }\n\n  async function read(file) {\n    const own = invalidate();\n    source = null;\n    checked = [];\n    selects = [];\n    mapping.replaceChildren();\n    delimiterField.hidden = true;\n    for (const name of ['file', 'mapping', 'preview']) steps[name].completed = false;\n    next.loading = Boolean(file);\n    updateNav();\n    if (!file) return;\n\n    const content = await readFile(file);\n    if (own !== generation) return;\n    next.loading = false;\n    source = { name: file.name, ...content };\n    delimiterField.value = delimiters[content.delimiter] ?? content.delimiter;\n    delimiterField.hidden = false;\n    buildMapping();\n    updateNav();\n  }\n\n  // One record per row: its line in the file — the column names take the first — and the mapped values.\n  function records() {\n    const keys = selects.map(select => select.value);\n    return source.rows.map((cells, index) => {\n      const record = { row: index + 2 };\n      keys.forEach((key, column) => {\n        if (key !== 'skip') record[key] = (cells[column] ?? '').trim();\n      });\n      return record;\n    });\n  }\n\n  function updateErrorList() {\n    if (errorFile) URL.revokeObjectURL(errorFile.url);\n    const { delimiter, headers, rows } = source;\n    // The file's own columns and the reason beside them, so the list can be corrected and imported again.\n    const lines = [\n      ['Row', ...headers, 'Finding'],\n      ...failed().map(record => [String(record.row), ...rows[record.row - 2], record.finding])\n    ];\n    const text = lines.map(line => line.map(value => cell(value, delimiter)).join(delimiter)).join('\\r\\n');\n    errorFile = {\n      name: `${source.name.replace(/\\.csv$/i, '')}-errors.csv`,\n      url: URL.createObjectURL(new Blob([byteOrderMark + text], { type: 'text/csv' }))\n    };\n  }\n\n  function showPreview() {\n    const bad = failed().length;\n    summary.replaceChildren(\n      element('span', { className: 'ed-font-weight-semibold ed-text-tabular', textContent: checked.length }),\n      ` ${checked.length === 1 ? 'row' : 'rows'} from ${source.name} · ${importable().length} will be imported`,\n      ...(bad\n        ? [\n            ' · ',\n            element('ed-badge', { variant: 'danger', textContent: bad === 1 ? '1 with an error' : `${bad} with errors` }),\n            bad === 1 ? ' is skipped' : ' are skipped'\n          ]\n        : [])\n    );\n    preview.label = `Preview of ${source.name}`;\n    preview.data = checked;\n    updateErrorList();\n  }\n\n  const leftOut = bad =>\n    bad ? ` ${count(bad, 'row')} with errors ${bad === 1 ? 'is' : 'are'} left out and kept in the error list.` : '';\n\n  async function run() {\n    const rows = importable();\n    const bad = failed().length;\n    running = true;\n    next.loading = true;\n    progress.value = 0;\n    progress.label = `0 of ${rows.length} imported`;\n    outcome.textContent = `${count(rows.length, 'employee')} ${rows.length === 1 ? 'is' : 'are'} being created.${leftOut(bad)}`;\n    updateNav();\n\n    const address = await importEmployees(rows, done => {\n      progress.value = (done / rows.length) * 100;\n      progress.label = `${done} of ${rows.length} imported`;\n    });\n    running = false;\n    finished = true;\n    next.loading = false;\n    await complete(steps.import);\n    result.href = address;\n    result.textContent = rows.length === 1 ? 'Show the employee' : `Show the ${rows.length} employees`;\n    outcome.textContent = `${count(rows.length, 'employee')} ${rows.length === 1 ? 'was' : 'were'} imported.${leftOut(bad)}`;\n    // The link takes the button's place, and the focus with it, or it would drop to the page.\n    const focused = document.activeElement === next;\n    updateNav();\n    if (focused) {\n      await result.updateComplete;\n      result.focus();\n    }\n  }\n\n  next.addEventListener('click', async () => {\n    const name = activeStep();\n    if (next.disabled || next.loading || running || finished || name === 'import' || problem(name)) return;\n    if (name === 'mapping') {\n      const own = generation;\n      next.loading = true;\n      const answer = await checkRecords(records());\n      if (own !== generation) return;\n      next.loading = false;\n      checked = answer;\n      showPreview();\n    }\n    await complete(steps[name]);\n    // Whoever went back in the meantime stays where they went.\n    if (activeStep() !== name) return;\n    wizard.next();\n    if (name === 'preview') run();\n  });\n\n  back.addEventListener('click', () => {\n    if (back.disabled) return;\n    invalidate();\n    wizard.previous();\n  });\n\n  errorList.addEventListener('click', () => {\n    // eurodata-ui-exception: ed-button takes no blob: address as its href, so a link of its own saves the\n    // generated list.\n    const link = element('a', { href: errorFile.url, download: errorFile.name });\n    document.body.append(link);\n    link.click();\n    link.remove();\n  });\n\n  wizard.addEventListener('ed-wizard-change', updateNav);\n  fileInput.addEventListener('ed-file-input-change', event => read(event.detail.files[0] ?? null));\n\n  if (fileAtStart) fileInput.value = [fileAtStart];\n  read(fileAtStart ?? null);\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/bulk-editing/be05-import-wizard/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/bulk-editing/"
}
