{
  "id": "TD02",
  "title": "Row edit in a dialog",
  "topic": "tables",
  "use": "A record is corrected from its row in a dialog. The table stays text; “Edit” at the end of the row opens the fields that change — with a check across the fields that would have no room in a cell. This is the way between the editable cell (TD01) and the detail page, for details that change rarely and under conditions.",
  "decisions": [
    "The cells stay text because the fields depend on each other: a check across employment, weekly hours and fixed term needs one place where all of them are visible, which is the dialog.",
    "One labeled action per row — Edit, with the person in its aria-label — and the row itself opens nothing: the name is no link, because an entry either leads somewhere or acts.",
    "The dialog shows the fields that change over a job, not the whole record: the name stands in its title and personnel number, team and start date in the subtitle, as the identity, and creating adds name and start date as the first row of the same dialog.",
    "The check says what to do, at the field it means, and the focus moves there — 'An internship has an end date. Enter it.' instead of 'required'.",
    "The dialog is wide enough for the fields to stand two by two: six fields in one column are a form to scroll.",
    "Closing a changed dialog asks first, by Escape, the close button and Cancel alike: an alert dialog names what is lost. Without a change it just closes.",
    "Saving shows in the row and in the line under the table; a toast appears only when the saved row is out of view. Each change can be undone once: more than one step would be a change history, which belongs in the personnel file.",
    "Selection serves the export, not the correction: Edit reaches one row and stands in the row, never in the toolbar.",
    "Only fixed terms ending within three months carry a badge ('ends in 31 days', counted from today), and parental leave is a badge on the employment with the return date in its title: the exception gets the badge, not the norm."
  ],
  "try": [
    {
      "do": "Press Edit for Yasemin Demir",
      "then": "the dialog shows six fields; the name stands in its title, personnel number, team and start date in the subtitle, and none of them can be changed"
    },
    {
      "do": "Empty Fixed-term until and press Save details",
      "then": "the date field says “An internship has an end date. Enter it.” and the focus moves there"
    },
    {
      "do": "Set Employment to Full-time for Elena Schwarz and save",
      "then": "the message says that full-time is 40 hours and that her 32 hours call for part-time"
    },
    {
      "do": "Change the location and save",
      "then": "the row shows the new location, the line under the table says Location changed, and no toast appears"
    },
    {
      "do": "Press Undo change",
      "then": "the old value stands in the row again"
    },
    {
      "do": "Change a field and press Escape",
      "then": "an alert dialog asks whether the changes are discarded"
    },
    {
      "do": "Change a field, click beside the dialog, then beside the question",
      "then": "the same question comes up; clicking beside it goes back to the dialog with the change kept"
    },
    {
      "do": "Press Add employee",
      "then": "the same dialog, with name and start date as its first row and the next personnel number in the subtitle"
    },
    {
      "do": "Type “Payroll” in the search",
      "then": "four rows remain, and the counter reads “4 of 20 entries”"
    },
    {
      "do": "Open Filter and choose Internship",
      "then": "the drawer comes in from the right, a badge stands above the table, and the button reads Filter (1)"
    },
    {
      "do": "Tick two rows",
      "then": "the counter adds “2 selected”, and Export becomes available"
    },
    {
      "do": "Click Fixed-term until",
      "then": "the column sorts, and those without an end come last"
    }
  ],
  "revision": 1,
  "changes": [],
  "reviewed": true,
  "source": "<!-- Demo only, not part of the pattern. The table takes the height the screen leaves it; here it is set to fit the frame. -->\n\n<div class=\"ed-stack ed-gap-4\">\n  <ed-data-grid id=\"td02-grid\" label=\"Employees\" with-search selectable selection-mode=\"multiple\" data-ed-preload=\"ed-format-date\">\n    <ed-button slot=\"toolbar-actions\" id=\"td02-add\">\n      <ed-icon slot=\"start\" name=\"plus\"></ed-icon>\n      Add employee\n    </ed-button>\n    <ed-button slot=\"toolbar-actions\" id=\"td02-export\" appearance=\"plain\" disabled title=\"Select at least one row.\">\n      <ed-icon slot=\"start\" name=\"export\"></ed-icon>\n      Export\n    </ed-button>\n    <ed-button slot=\"toolbar-view\" id=\"td02-filter\" appearance=\"plain\">\n      <ed-icon slot=\"start\" name=\"filter\"></ed-icon>\n      <span id=\"td02-filter-label\">Filter</span>\n    </ed-button>\n    <ed-empty-state slot=\"no-results\" size=\"medium\" icon=\"search\">\n      <span slot=\"label\">Nobody matches the search and filters</span>\n      <span id=\"td02-empty-text\"></span>\n      <ed-button slot=\"button\" id=\"td02-empty-reset\" appearance=\"outlined\">\n        <ed-icon slot=\"start\" name=\"close\"></ed-icon>\n        Reset all filters\n      </ed-button>\n    </ed-empty-state>\n  </ed-data-grid>\n\n  <div class=\"ed-cluster ed-gap-3 ed-align-items-center\">\n    <p id=\"td02-status\" role=\"status\"></p>\n    <ed-button id=\"td02-undo\" appearance=\"outlined\" density=\"compact\" disabled title=\"There is no change yet.\">\n      <ed-icon slot=\"start\" name=\"undo\"></ed-icon>\n      Undo change\n    </ed-button>\n  </div>\n\n  <ed-drawer id=\"td02-drawer\" light-dismiss label=\"Filter\" placement=\"end\" with-footer style=\"--ed-drawer-size: 22rem\">\n    <div class=\"ed-stack ed-gap-4\">\n      <ed-select data-column=\"jobTitle\" label=\"Job title\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"location\" label=\"Location\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"employment\" label=\"Employment\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"endDate\" label=\"Fixed term\" placeholder=\"Several\"></ed-select>\n    </div>\n    <ed-button slot=\"footer\" data-drawer=\"close\">Done</ed-button>\n    <ed-button slot=\"footer\" class=\"td02-reset\" appearance=\"plain\">Reset all filters</ed-button>\n  </ed-drawer>\n\n  <ed-dialog id=\"td02-dialog\" light-dismiss label=\"Edit details\" style=\"--ed-dialog-width: 44rem\">\n    <span slot=\"sublabel\" id=\"td02-sublabel\"></span>\n    <div class=\"ed-grid ed-gap-5\" style=\"--ed-grid-min-column-size: 16rem\">\n      <ed-input id=\"td02-name\" label=\"Name\" required autocomplete=\"off\" hidden></ed-input>\n      <ed-date-picker id=\"td02-start\" type=\"date\" label=\"Start date\" required hidden></ed-date-picker>\n      <ed-select id=\"td02-job\" label=\"Job title\" required></ed-select>\n      <ed-select id=\"td02-location\" label=\"Location\" required></ed-select>\n      <ed-select id=\"td02-employment\" label=\"Employment\" required></ed-select>\n      <ed-input id=\"td02-hours\" type=\"number\" label=\"Weekly hours\" min=\"1\" max=\"40\" step=\"any\" without-spin-buttons hint=\"Full-time is 40 hours\"></ed-input>\n      <ed-date-picker id=\"td02-end\" type=\"date\" label=\"Fixed-term until\" with-clear hint=\"Empty means permanent\"></ed-date-picker>\n      <ed-input id=\"td02-cost-center\" label=\"Cost center\" maxlength=\"4\" inputmode=\"numeric\" hint=\"4 digits\"></ed-input>\n    </div>\n    <ed-button slot=\"footer\" id=\"td02-save\">Save details</ed-button>\n    <ed-button slot=\"footer\" appearance=\"outlined\" data-dialog=\"close\">Cancel</ed-button>\n  </ed-dialog>\n\n  <ed-alert-dialog id=\"td02-discard\" light-dismiss type=\"warning\" label=\"Discard the changes\" confirm-text=\"Discard changes\" cancel-text=\"Keep editing\">\n    <span id=\"td02-discard-text\"></span>\n  </ed-alert-dialog>\n\n  <ed-toast-stack id=\"td02-toasts\"></ed-toast-stack>\n</div>\n\n<!-- Demo only, not part of the pattern. The product supplies employees, the rows as { id, personnelNo, name, jobTitle, team, location, startDate, endDate, employment, hours, costCenter, parentalLeaveUntil } with dates as YYYY-MM-DD, and endDate and parentalLeaveUntil undefined where they do not apply; the value lists JOB_TITLES and LOCATIONS; DEFAULT_LOCATION, the location a new employee starts with; and TODAY, the current date as YYYY-MM-DD, against which the fixed terms are counted. -->\n\n<!-- Demo only, not part of the pattern. The product supplies nextPersonnelNumber(), the number the next new employee gets, and assignPersonnelNumber(), which hands that number out; a number is given once, so one taken back with Undo is not handed out again. -->\n\n<script type=\"module\">\n  const grid = document.getElementById('td02-grid');\n  const status = document.getElementById('td02-status');\n  const undo = document.getElementById('td02-undo');\n  const exportButton = document.getElementById('td02-export');\n  const drawer = document.getElementById('td02-drawer');\n  const filterLabel = document.getElementById('td02-filter-label');\n  const filterFields = [...drawer.querySelectorAll('ed-select')];\n  const emptyText = document.getElementById('td02-empty-text');\n  const dialog = document.getElementById('td02-dialog');\n  const sublabel = document.getElementById('td02-sublabel');\n  const save = document.getElementById('td02-save');\n  const discard = document.getElementById('td02-discard');\n  const toasts = document.getElementById('td02-toasts');\n\n  const fields = {\n    name: document.getElementById('td02-name'),\n    startDate: document.getElementById('td02-start'),\n    jobTitle: document.getElementById('td02-job'),\n    location: document.getElementById('td02-location'),\n    employment: document.getElementById('td02-employment'),\n    hours: document.getElementById('td02-hours'),\n    endDate: document.getElementById('td02-end'),\n    costCenter: document.getElementById('td02-cost-center')\n  };\n  const EDITED = {\n    jobTitle: 'Job title',\n    location: 'Location',\n    employment: 'Employment',\n    hours: 'Weekly hours',\n    endDate: 'Fixed term',\n    costCenter: 'Cost center'\n  };\n  const EMPLOYMENTS = ['Full-time', 'Part-time', 'Apprenticeship', 'Internship', 'Working student'];\n  const FIXED_TERM = { Apprenticeship: 'An apprenticeship', Internship: 'An internship', 'Working student': 'A working student contract' };\n  const FULL_TIME = 40;\n  const SOON = 90;\n\n  const day = value =>\n    Object.assign(document.createElement('ed-format-date'), { date: value, month: 'short', day: 'numeric', year: 'numeric' });\n  const longDay = value =>\n    Object.assign(document.createElement('ed-format-date'), { date: value, month: 'long', day: 'numeric', year: 'numeric' });\n  // A title and a field's error are text and cannot hold an element, so the dates in them are written here.\n  const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });\n  const longDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' });\n  const daysUntil = day => Math.round((Date.parse(day) - Date.parse(TODAY)) / 86400000);\n\n  // The fixed-term filter is a question with three answers, asked of the end date against today.\n  const FIXED_TERM_ANSWERS = { fixed: 'Fixed-term', ending: 'Ends in the next 3 months', permanent: 'Permanent' };\n  const answers = (end, answer) =>\n    answer === 'permanent' ? !end : answer === 'fixed' ? Boolean(end) : Boolean(end) && daysUntil(end) >= 0 && daysUntil(end) <= SOON;\n\n  function withBadge(content, label, variant, title) {\n    const wrapper = document.createElement('span');\n    const badge = document.createElement('ed-badge');\n    badge.variant = variant;\n    badge.textContent = label;\n    if (title) badge.title = title;\n    wrapper.append(content, ' ', badge);\n    return wrapper;\n  }\n\n  // Only the fixed terms that end in the next three months carry a badge.\n  function endCell(value) {\n    if (!value) return '—';\n    const days = daysUntil(value);\n    if (days < 0 || days > SOON) return day(value);\n    return withBadge(day(value), days === 0 ? 'ends today' : days === 1 ? 'ends tomorrow' : `ends in ${days} days`, 'warning');\n  }\n\n  function employmentCell(value, row) {\n    const text = `${value}, ${row.hours} h`;\n    if (!row.parentalLeaveUntil) return text;\n    const back = new Date(Date.parse(row.parentalLeaveUntil) + 86400000);\n    return withBadge(text, 'Parental leave', 'info', `Returns on ${date.format(back)}`);\n  }\n\n  const buttons = new Map();\n\n  function editButton(row) {\n    if (!buttons.has(row.id)) {\n      const button = document.createElement('ed-button');\n      const icon = document.createElement('ed-icon');\n      icon.slot = 'start';\n      icon.name = 'edit';\n      button.append(icon, 'Edit');\n      button.setAttribute('appearance', 'plain');\n      button.setAttribute('density', 'compact');\n      button.setAttribute('aria-label', `Edit details of ${row.name}`);\n      button.addEventListener('click', () => open(row));\n      buttons.set(row.id, button);\n    }\n    return buttons.get(row.id);\n  }\n\n  grid.columns = [\n    { field: 'name', label: 'Name', rowheader: true, flex: 1, minWidth: 180 },\n    { field: 'personnelNo', label: 'Personnel no.', align: 'end', width: 110 },\n    { field: 'jobTitle', label: 'Job title', width: 150, filterable: true, filterType: 'set' },\n    { field: 'location', label: 'Location', width: 110, filterable: true, filterType: 'set' },\n    { field: 'startDate', label: 'Start date', width: 110, sortFn: 'datetime', formatter: day },\n    {\n      field: 'endDate',\n      label: 'Fixed-term until',\n      width: 195,\n      sortFn: 'datetime',\n      sortUndefined: 'last',\n      filterable: true,\n      filterType: 'set',\n      filterOptions: Object.entries(FIXED_TERM_ANSWERS).map(([value, label]) => ({ value, label })),\n      filterFn: (value, filter) => [filter].flat().some(answer => answers(value, answer)),\n      formatter: endCell\n    },\n    { field: 'employment', label: 'Employment', width: 190, filterable: true, filterType: 'set', formatter: employmentCell },\n    { id: 'actions', label: 'Action', align: 'end', width: 90, pinned: 'right', formatter: (value, row) => editButton(row) }\n  ];\n  grid.data = employees;\n\n  const visible = row => grid.getProcessedRows().includes(row);\n\n  function updateExport() {\n    exportButton.disabled = grid.selectedRows.length === 0;\n    exportButton.setAttribute('title', exportButton.disabled ? 'Select at least one row.' : '');\n  }\n\n  grid.addEventListener('ed-data-grid-row-select', updateExport);\n\n  exportButton.addEventListener('click', () => {\n    const selected = grid.selectedRows.length;\n    toasts.create({ variant: 'success', title: selected === 1 ? '1 person exported' : `${selected} employees exported` });\n  });\n\n  // The filter: the dimensions with a list of values; name and personnel number go into the search.\n  // The values are those the rows hold when the drawer opens.\n  const active = key => [grid.filters.find(filter => filter.id === key)?.value ?? []].flat();\n  const held = (list, key) => list.filter(value => employees.some(row => row[key] === value) || active(key).includes(value));\n  const filterValues = {\n    jobTitle: () => held(JOB_TITLES, 'jobTitle').map(value => [value, value]),\n    location: () => LOCATIONS.map(value => [value, value]),\n    employment: () => held(EMPLOYMENTS, 'employment').map(value => [value, value]),\n    endDate: () => Object.entries(FIXED_TERM_ANSWERS)\n  };\n\n  function option(value, label = value) {\n    const item = document.createElement('ed-option');\n    item.value = value;\n    item.textContent = label;\n    return item;\n  }\n\n  function fillFilters() {\n    for (const field of filterFields) {\n      field.replaceChildren(option('', 'All'), ...filterValues[field.dataset.column]().map(([value, label]) => option(value, label)));\n    }\n    showFilters();\n  }\n\n  for (const field of filterFields) {\n    field.addEventListener('change', () => {\n      const key = field.dataset.column;\n      const others = grid.filters.filter(filter => filter.id !== key);\n      grid.filters = field.value ? [...others, { id: key, value: [field.value] }] : others;\n      showFilters();\n    });\n  }\n\n  // The column's own panel can set several values; the select has no option for that and shows its\n  // placeholder instead.\n  function showFilters() {\n    for (const field of filterFields) {\n      const value = active(field.dataset.column);\n      field.value = value.length > 1 ? null : (value[0] ?? '');\n    }\n    filterLabel.textContent = grid.filters.length ? `Filter (${grid.filters.length})` : 'Filter';\n    const cause = grid.filters.length && grid.searchTerm.trim() ? 'the search and filters' : grid.filters.length ? 'the filters set' : 'the search';\n    emptyText.textContent = `There are ${employees.length} employees — just nobody who matches ${cause}.`;\n  }\n\n  grid.addEventListener('ed-data-grid-filter-change', showFilters);\n  drawer.addEventListener('ed-show', fillFilters);\n  document.getElementById('td02-filter').addEventListener('click', () => {\n    drawer.open = !drawer.open;\n  });\n  for (const reset of document.querySelectorAll('.td02-reset, #td02-empty-reset')) {\n    reset.addEventListener('click', () => {\n      grid.filters = [];\n      showFilters();\n    });\n  }\n\n  // The dialog. The record's identity is the name in the title and personnel number, team and start\n  // date in the subtitle; creating adds name and start date as the first row.\n  fields.jobTitle.append(...JOB_TITLES.map(value => option(value)));\n  fields.location.append(...LOCATIONS.map(value => option(value)));\n  fields.employment.append(...EMPLOYMENTS.map(value => option(value)));\n\n  let current = null;\n  let snapshot = '';\n  let allowClose = false;\n  let checked = false;\n  let last = null;\n\n  const read =() => Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, String(field.value ?? '').trim()]));\n\n  function open(row) {\n    current = row;\n    const creating = row === null;\n    fields.name.hidden = fields.startDate.hidden = !creating;\n    fields.name.toggleAttribute('autofocus', creating);\n    fields.jobTitle.toggleAttribute('autofocus', !creating);\n    dialog.label = creating ? 'Add employee' : `Edit details of ${row.name}`;\n    sublabel.replaceChildren(\n      ...(creating\n        ? [`Personnel no. ${nextPersonnelNumber()} will be assigned`]\n        : [`Personnel no. ${row.personnelNo} · ${row.team} · since `, longDay(row.startDate)])\n    );\n    save.textContent = creating ? 'Add employee' : 'Save details';\n    discard.label = creating ? 'Discard the new employee' : `Discard the changes to ${row.name}`;\n    document.getElementById('td02-discard-text').textContent = creating\n      ? 'The details entered are lost.'\n      : `The changed details of ${row.name} are lost.`;\n    discard.confirmText = creating ? 'Discard entries' : 'Discard changes';\n\n    const values = creating ? { location: DEFAULT_LOCATION, employment: 'Full-time', hours: FULL_TIME } : row;\n    for (const [key, field] of Object.entries(fields)) {\n      field.value = values[key] == null ? '' : String(values[key]);\n      field.error = null;\n    }\n    snapshot = JSON.stringify(read());\n    allowClose = false;\n    checked = false;\n    dialog.open = true;\n  }\n\n  // The check across the fields says what to do, at the field it means.\n  function check(values) {\n    let first = null;\n    const fail = (key, message) => {\n      fields[key].error = message;\n      first ??= fields[key];\n    };\n    for (const field of Object.values(fields)) field.error = null;\n\n    const creating = current === null;\n    const hours = Number(values.hours);\n    const start = creating ? values.startDate : current.startDate;\n\n    if (creating && !values.name) fail('name', 'Enter the name as it appears on the payslip.');\n    if (creating && !values.startDate) fail('startDate', 'Enter the start date.');\n    if (!values.jobTitle) fail('jobTitle', 'Choose the job title.');\n    if (!values.location) fail('location', 'Choose the location.');\n    if (!values.hours || !Number.isFinite(hours) || hours < 1 || hours > FULL_TIME) {\n      fail('hours', `Between 1 and ${FULL_TIME} hours a week.`);\n    } else if (values.employment === 'Full-time' && hours < FULL_TIME) {\n      fail('employment', `Full-time is ${FULL_TIME} hours. For ${hours} hours, choose part-time.`);\n    } else if (values.employment === 'Part-time' && hours === FULL_TIME) {\n      fail('employment', `${FULL_TIME} hours is full-time.`);\n    }\n    if (FIXED_TERM[values.employment] && !values.endDate) {\n      fail('endDate', `${FIXED_TERM[values.employment]} has an end date. Enter it.`);\n    } else if (values.endDate && start && values.endDate <= start) {\n      fail('endDate', `The end lies before the start on ${longDate.format(new Date(start))}.`);\n    }\n    if (!/^\\d{4}$/.test(values.costCenter)) fail('costCenter', 'The cost center has 4 digits, for example 4700.');\n    return first;\n  }\n\n  // The changed row is the confirmation; a toast says it only where the row is out of view.\n  function report(row, done, undoes, toast) {\n    grid.data = employees;\n    status.textContent = done;\n    undo.disabled = false;\n    undo.setAttribute('title', `Undoes: ${undoes}`);\n    grid.updateComplete.then(() => {\n      if (!visible(row)) toasts.create({ variant: 'success', title: toast, description: 'The row is outside the current view.' });\n    });\n  }\n\n  save.addEventListener('click', () => {\n    const values = read();\n    checked = true;\n    const first = check(values);\n    if (first) {\n      first.focus();\n      return;\n    }\n\n    const details = {\n      jobTitle: values.jobTitle,\n      location: values.location,\n      employment: values.employment,\n      hours: Number(values.hours),\n      endDate: values.endDate || undefined,\n      costCenter: values.costCenter\n    };\n    allowClose = true;\n    dialog.open = false;\n\n    if (current) {\n      const changed = Object.keys(EDITED).filter(key => String(current[key] ?? '') !== String(details[key] ?? ''));\n      if (!changed.length) return;\n      const names = changed.map(key => EDITED[key]).join(', ');\n      last = { row: current, before: Object.fromEntries(Object.keys(EDITED).map(key => [key, current[key]])) };\n      Object.assign(current, details);\n      report(current, `Details of ${current.name} changed: ${names}`, `${names} of ${current.name}`, `${current.name} saved`);\n    } else {\n      const personnelNo = assignPersonnelNumber();\n      const row = { id: personnelNo, personnelNo, name: values.name, team: '—', startDate: values.startDate, ...details };\n      employees.push(row);\n      last = { row, added: true };\n      report(row, `${row.name} added, personnel no. ${row.personnelNo}`, `${row.name} added`, `${row.name} added`);\n    }\n  });\n\n  undo.addEventListener('click', () => {\n    const { row } = last;\n    if (last.added) {\n      employees.splice(employees.indexOf(row), 1);\n      buttons.delete(row.id);\n      grid.selectedKeys = grid.selectedKeys.filter(key => key !== row.id);\n      updateExport();\n    } else {\n      Object.assign(row, last.before);\n    }\n    grid.data = employees;\n    status.textContent = last.added ? `${row.name} is removed again.` : `The change to ${row.name} is undone.`;\n    last = null;\n    undo.disabled = true;\n    undo.setAttribute('title', 'There is no change yet.');\n    // The button the focus was on is disabled now; the focus goes back to the row.\n    requestAnimationFrame(() => {\n      const button = buttons.get(row.id);\n      (button?.isConnected ? button : document.getElementById('td02-add')).focus();\n    });\n  });\n\n  // After the first save, the check runs as the user types, so a corrected field loses its message.\n  dialog.addEventListener('input', () => {\n    if (checked) check(read());\n  });\n\n  // Cancel, Escape and the close button do the same: without a change they close, with one they ask.\n  dialog.addEventListener('ed-hide', event => {\n    if (event.target !== dialog || allowClose || JSON.stringify(read()) === snapshot) return;\n    event.preventDefault();\n    discard.open = true;\n  });\n\n  discard.addEventListener('ed-alert-dialog-confirm', () => {\n    allowClose = true;\n  });\n\n  discard.addEventListener('ed-after-hide', () => {\n    if (allowClose) dialog.open = false;\n  });\n\n  document.getElementById('td02-add').addEventListener('click', () => open(null));\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/td02-row-edit-in-a-dialog/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/"
}
