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();