declarative-forms

4 min read Original article ↗

ts

import 'declarative-forms/styles.css';
import { DeclarativeForm, html } from 'declarative-forms';

// Pretend this is your API. Any options function may be async.
const reviewersOf = async (team) => {
  await new Promise((resolve) => setTimeout(resolve, 500));
  return (
    {
      design: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'],
      infra: ['Radia Perlman', 'Alan Turing'],
    }[team] ?? []
  );
};

// A button in the dialog below opens this one, so it appears on top of it.
// Its own list then opens a third dialog on top of that.
const openSchedule = (release) =>
  new DeclarativeForm({
    fields: [
      {
        name: 'when',
        kind: 'cards',
        displayName: 'Publish',
        defaultValue: 'now',
        cards: [
          { value: 'now', content: html('<b>Immediately</b><br>on confirm') },
          { value: 'at', content: html('<b>At a set time</b><br>your timezone') },
        ],
      },
      {
        name: 'at',
        type: 'datetime-local',
        displayName: 'Moment',
        isActive: ({ data }) => data['when'] === 'at',
      },
      {
        name: 'freezes',
        kind: 'array',
        displayName: 'Never publish during',
        newButtonLabel: 'Add window',
        of: [
          { name: 'reason', displayName: 'Reason', placeholder: 'Conference' },
          { name: 'until', type: 'date', displayName: 'Until' },
        ],
        renderEntry: (entry) => `${entry['reason']} — until ${entry['until']}`,
        isValidRecord: (entry) => Boolean(entry['reason'] && entry['until']),
        suggested: [{ reason: 'Company all-hands', until: '2026-09-01' }],
      },
      {
        name: 'recap',
        kind: 'message',
        // The values of every open dialog, outermost first.
        message: ({ data, stackData }) =>
          html(`Publishing <b>${stackData[0]['title'] || 'this release'}</b>
                ${data['when'] === 'now' ? 'as soon as you confirm' : 'later'}.`),
      },
    ],
    buttons: {
      Apply: {
        id: 'apply',
        action: (values) => {
          const at = values['when'] === 'now' ? 'Immediately' : values['at'];
          release.field('publishAt').setValue(at);
          void release.update();
        },
      },
    },
    onCancel: () => {},
  }).openInModal();

const release = new DeclarativeForm({
  fields: [
    // `tab` groups fields. The tab bar builds itself, and hides a tab when
    // none of its fields is active.
    {
      name: 'title',
      displayName: 'Release title',
      tab: 'Notes',
      placeholder: 'Sunrise 2.0',
      tooltip: 'Shown at the top of the changelog',
    },
    { name: 'notes', kind: 'textarea', displayName: 'What changed', tab: 'Notes' },
    {
      // Derived, and never shown. Recalculated before any button action runs.
      // Watch `slug` in the values panel above.
      name: 'slug',
      kind: 'computed',
      compute: ({ data }) =>
        '/releases/' +
        String(data['title'] || 'untitled')
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-'),
    },
    {
      name: 'publishAt',
      displayName: 'Publish at',
      tab: 'Notes',
      defaultValue: 'Immediately',
    },
    {
      name: 'visibility',
      kind: 'select',
      displayName: 'Visible to',
      tab: 'Audience',
      defaultValue: 'team',
      options: [
        { value: 'team', label: 'One team' },
        { value: 'company', label: 'Everyone here' },
        { value: 'public', label: 'The public' },
      ],
    },
    {
      name: 'team',
      kind: 'select',
      displayName: 'Which team',
      tab: 'Audience',
      defaultValue: 'design',
      options: [
        { value: 'design', label: 'Design' },
        { value: 'infra', label: 'Infrastructure' },
      ],
      // A hidden field disappears from getValues() completely.
      isActive: ({ data }) => data['visibility'] === 'team',
    },
    {
      name: 'reviewers',
      kind: 'select',
      multiple: true,
      displayName: 'Sign-off from',
      tab: 'Audience',
      isActive: ({ data }) => data['visibility'] === 'team',
      reloadOnChangeOf: ['team'], // reloads, and ignores out-of-date answers
      options: ({ data }) => reviewersOf(data['team']),
    },
    {
      name: 'announce',
      kind: 'checkbox',
      tab: 'Audience',
      label: 'Post to #releases when it goes live',
    },
    {
      name: 'credits',
      kind: 'array',
      displayName: 'Credits',
      tab: 'Credits',
      newButtonLabel: 'Add person',
      of: [
        { name: 'who', displayName: 'Name' },
        {
          name: 'role',
          kind: 'select',
          displayName: 'Role',
          options: ['Author', 'Reviewer', 'Release manager'],
          defaultValue: 'Author',
        },
      ],
      renderEntry: (entry) => `${entry['who']} — ${entry['role']}`,
      isValidRecord: (entry) => String(entry['who'] ?? '').trim() !== '',
      suggested: [{ who: 'Ada Lovelace', role: 'Author' }],
    },
  ],
  buttons: {
    Publish: {
      id: 'publish',
      // The button stays disabled until this returns true. May be async.
      isActive: ({ data }) => Boolean(data['title']) && data['credits']?.length > 0,
      action: (values) => console.log(values),
    },
    'Schedule…': {
      id: 'schedule',
      class: 'secondary',
      doNotCloseModal: true, // this dialog stays open underneath
      action: () => openSchedule(release),
    },
  },
  onCancel: () => {},
});

release.openInModal();