Files
website/client/src/routes/admin/views/EngagementTemplates.jsx
wtclaude 3f90070566
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:13:57 -05:00

650 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
//
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
// screen that lets someone change one, and its whole shape follows from a single
// fact about email:
//
// **the server renders the mail, so the server renders the preview.**
//
// There is no React renderer for an `email.*` block anywhere in this client. The
// preview is HTML the server produced with the same call the send path uses,
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
// below — and buys the only property that matters on a screen like this: what is
// on screen is what will arrive, not a second implementation's opinion of it.
//
// **The sandbox is a security boundary, not a nicety.** The preview is
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
// reason the server's checks are asserted: this is the kind of attribute someone
// removes while debugging and does not put back.
//
// What the operator can do here is deliberately bounded (settled with the org
// lead at the start of the phase):
//
// • **A shipped default is edited in place.** `protected` blocks deletion and
// nothing else; saving sets `customized = 1`, which is what stops the next
// seed bump from taking the edit back.
// • **Duplicate is the only way to a new template**, so every template on a
// deployment descends from one that renders.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
// Three widths, because a mail body has to survive all of them and the failures
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
// plain-text part is what a text-only client and every screen reader gets.
const WIDTHS = [
['desktop', 'Desktop', 640],
['mobile', 'Mobile', 360],
]
/** Short, human label for a template's channel. */
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
// ── The preview frame ──────────────────────────────────────────────────────
/**
* The rendered HTML, in a sandboxed frame.
*
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
* what Apple Mail and Outlook do to a light-only message, which is the failure
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
* on screen — the alternative, rendering a second dark palette server-side, would
* be a preview of a mail this system does not send.
*/
function PreviewFrame({ html, width, dark }) {
return (
<div
style={{
background: dark ? '#1b1b1b' : '#f4f4f5',
padding: 12,
borderRadius: 6,
overflowX: 'auto',
}}
>
<iframe
// No allow-scripts, and no allow-same-origin. Both omissions are load
// bearing; see this file's header.
sandbox=""
srcDoc={html || ''}
title="Message preview"
style={{
width,
maxWidth: '100%',
height: 520,
border: '1px solid var(--rule)',
borderRadius: 4,
background: '#fff',
display: 'block',
margin: '0 auto',
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
}}
/>
</div>
)
}
// ── The editor ─────────────────────────────────────────────────────────────
function TemplateEditor({ template, triggers, onDone, onCancel }) {
const [name, setName] = useState(template.name)
const [subject, setSubject] = useState(template.subject || '')
const [blocks, setBlocks] = useState(template.blocks || [])
const [textBody, setTextBody] = useState(template.text_body || '')
const [status, setStatus] = useState(template.status)
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [tab, setTab] = useState('html')
const [width, setWidth] = useState('desktop')
const [dark, setDark] = useState(false)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState([])
const [saved, setSaved] = useState(false)
const [testTo, setTestTo] = useState('')
const [testState, setTestState] = useState(null)
// The variable palette. It comes from the server with the row and is refreshed
// by every preview, because re-pointing the template at another trigger changes
// it and the server is the one that knows what that trigger declares.
const [variables, setVariables] = useState(template.variables || [])
const draft = useMemo(
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
[name, subject, blocks, textBody, status, triggerId],
)
// Debounced preview. The delay is not about server load — it is one small
// render — but about the frame: re-mounting an iframe on every keystroke makes
// the preview flicker and steals nothing back.
const timer = useRef(null)
useEffect(() => {
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(async () => {
try {
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
const result = await api.admin.previewEngagementTemplate(template.id, body)
setPreview(result)
setPreviewError(null)
if (Array.isArray(result.variables)) setVariables(result.variables)
} catch (err) {
// A preview failure is expected while a block is half-edited, so it is
// shown where the preview would be rather than as a page-level error.
setPreviewError(err.body?.errors?.join(' · ') || err.message)
}
}, 400)
return () => timer.current && clearTimeout(timer.current)
}, [draft, template.id])
const selectedBlock = blocks.find((b) => b.id === selected) || null
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
const updateBlock = (id, props) =>
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
const addBlock = (type) => {
const block = newEmailBlock(type)
if (!block) return
setBlocks((bs) => [...bs, block])
setSelected(block.id)
}
const move = (id, delta) =>
setBlocks((bs) => {
const i = bs.findIndex((b) => b.id === id)
const j = i + delta
if (i < 0 || j < 0 || j >= bs.length) return bs
const next = [...bs]
;[next[i], next[j]] = [next[j], next[i]]
return next
})
const removeBlock = (id) =>
setBlocks((bs) => {
const next = bs.filter((b) => b.id !== id)
if (selected === id) setSelected(next[0]?.id || null)
return next
})
async function save() {
setSaving(true)
setErrors([])
setSaved(false)
try {
await api.admin.updateEngagementTemplate(template.id, draft)
setSaved(true)
onDone()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
} finally {
setSaving(false)
}
}
async function sendTest() {
setTestState({ busy: true })
try {
const body = { ...draft, to: testTo }
const result = await api.admin.testSendEngagementTemplate(template.id, body)
setTestState({ ok: true, message: `Sent to ${result.to}.` })
} catch (err) {
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
}
}
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
{template.protected && ' · part of the system'}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{errors.length > 0 && (
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
</div>
)}
{saved && errors.length === 0 && (
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
{/* ── Authoring ── */}
<div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
{template.channel === 'email' && (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Subject</span>
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
</label>
)}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
{/* "None" is the right default and not a missing value: every
transactional template is tied to no trigger — mailer renders
it by key with no rule involved. */}
<option value="">None used by key, not by a rule</option>
{triggers.map((t) => (
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
))}
</select>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
The trigger decides which variables this template may use.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Status</span>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="draft">Draft the shipped default is sent instead</option>
<option value="published">Published this is what goes out</option>
</select>
</label>
</div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
{blocks.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
)}
{blocks.map((b, i) => {
const def = getEmailBlock(b.type)
return (
<div
key={b.id}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
borderRadius: 4, cursor: 'pointer',
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
}}
onClick={() => setSelected(b.id)}
>
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
{/* An unknown type is a client/server version skew, and saying
so beats rendering a blank row the operator cannot act on. */}
{def ? def.label : `${b.type} (not known to this client)`}
</span>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}></button>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}></button>
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
</div>
)
})}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
{listEmailBlocks().map((def) => (
<button key={def.type} type="button" className="pill" title={def.hint}
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
+ {def.label}
</button>
))}
</div>
</div>
{selectedBlock && selectedDef?.editor && (
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
<selectedDef.editor
props={selectedBlock.props || {}}
variables={variables}
onChange={(props) => updateBlock(selectedBlock.id, props)}
/>
</div>
)}
<div className="panel" style={{ padding: 18 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Plain-text part (optional override)</span>
<textarea
className="input" rows={5} value={textBody}
placeholder="Leave blank to generate it from the blocks above."
onChange={(e) => setTextBody(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Every message has both parts. Writing one here REPLACES the generated text entirely.
</span>
</label>
</div>
</div>
{/* ── Preview ── */}
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
onClick={() => setTab('html')}>HTML</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
onClick={() => setTab('text')}>Plain text</button>
{tab === 'html' && (
<>
<span style={{ width: 10 }} />
{WIDTHS.map(([id, label]) => (
<button key={id} type="button" className="pill"
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
onClick={() => setWidth(id)}>{label}</button>
))}
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
onClick={() => setDark((d) => !d)}>Dark mode</button>
</>
)}
</div>
{previewError ? (
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
</div>
) : !preview ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering</p>
) : tab === 'html' ? (
<>
{template.channel === 'email' && (
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
</p>
)}
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
{dark && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
An approximation of how a client that inverts a light-only message will show it.
</p>
)}
</>
) : (
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
{preview.text || '(empty — a published template is refused with no text part)'}
</pre>
)}
{preview?.missing?.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
No example value for: {preview.missing.join(', ')} these render as nothing here and
will carry real values when the message is actually sent.
</p>
)}
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
Sends what is on screen, saved or not, through the configured transport.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input" type="email" placeholder="you@example.com" value={testTo}
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
{testState?.busy ? 'Sending…' : 'Send'}
</button>
</div>
{testState && !testState.busy && (
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
{testState.message}
</p>
)}
</div>
</div>
</div>
</section>
)
}
/** The variable tokens, for the two fields that are not block props. */
function VariableButtons({ variables, onInsert }) {
if (!variables?.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{variables.map((v) => (
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
title={`${v.type || 'string'}${v.description ? `${v.description}` : ''}`}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
onClick={() => onInsert(`{{${v.name}}}`)}>
{v.name}
</button>
))}
</div>
)
}
// ── Duplicate ──────────────────────────────────────────────────────────────
function DuplicateForm({ source, triggers, onDone, onCancel }) {
const [key, setKey] = useState('')
const [name, setName] = useState(`${source.name} (copy)`)
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
const [errors, setErrors] = useState([])
async function submit(e) {
e.preventDefault()
setErrors([])
try {
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
onDone(template)
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate {source.name}</h3>
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
The copy starts as a draft, so nothing sends it until you publish it.
</p>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Key</span>
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
onChange={(e) => setKey(e.target.value)} />
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
changed afterwards.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
<option value="">None used by key, not by a rule</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The list ───────────────────────────────────────────────────────────────
export default function EngagementTemplates() {
const [templates, setTemplates] = useState([])
const [triggers, setTriggers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [rowError, setRowError] = useState(null)
const [editing, setEditing] = useState(null)
const [duplicating, setDuplicating] = useState(null)
const load = useCallback(async () => {
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
setTemplates(t.templates || [])
setTriggers(tr.triggers || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
await load()
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [load])
async function open(row) {
setRowError(null)
try {
const { template } = await api.admin.getEngagementTemplate(row.id)
setEditing(template)
} catch (err) {
setRowError(err.message)
}
}
async function remove(row) {
if (!window.confirm(`Delete “${row.name}”?`)) return
setRowError(null)
try {
await api.admin.deleteEngagementTemplate(row.id)
await load()
} catch (err) {
setRowError(err.body?.errors?.join(' · ') || err.message)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
if (editing) {
return (
<TemplateEditor
template={editing}
triggers={triggers}
onDone={load}
onCancel={async () => { setEditing(null); await load() }}
/>
)
}
return (
<section>
{duplicating && (
<DuplicateForm
source={duplicating}
triggers={triggers}
onCancel={() => setDuplicating(null)}
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
/>
)}
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
Every message this deployment sends. The shipped ones are editable your edits survive
upgrades and cannot be deleted, because the system breaks without them. To make a new
template, duplicate one that already works.
</p>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Key</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="adm-td">
{t.name}
{t.protected && (
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
)}
<Flags template={t} />
</td>
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => open(t)}>Edit</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => setDuplicating(t)}>Duplicate</button>
<button type="button" className="pill"
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
disabled={t.protected}
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
onClick={() => remove(t)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
/**
* The three warnings a row can carry. Each is a different fact and they are worded
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
* nothing to someone who has not read the design document.
*/
function Flags({ template }) {
const notes = []
if (template.dormant) {
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
}
if (template.triggerBehind) {
notes.push('Its trigger has changed since this was written; check the variables still exist.')
}
if (template.seedBehind) {
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
}
if (!notes.length) return null
return (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{notes.map((n) => <div key={n}>{n}</div>)}
</div>
)
}