Record form
betaA list of detection rules whose Create and Edit open one form in a sheet, with the field checks, a server check and a partial success.
Detection rules
| Actions | |||||
|---|---|---|---|---|---|
Brute-force login Many failed sign-ins for one account from one address. | High | Authentication logs | Enabled | 2026-10-24 | |
Impossible travel Sign-ins for one account from two countries within an hour. | Medium | Authentication logs | Enabled | 2026-10-20 | |
Ransomware file renames | Critical | EDR events | Disabled | 2026-10-12 |
record-form.tsx656 lines
import * as React from "react"
import {
BellOffIcon,
MoreHorizontalIcon,
PlusIcon,
RadarIcon,
} from "lucide-react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@zenoxai/ui/components/alert"
import { Button } from "@zenoxai/ui/components/button"
import { Checkbox } from "@zenoxai/ui/components/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@zenoxai/ui/components/dropdown-menu"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldOptional,
FieldSet,
} from "@zenoxai/ui/components/field"
import { Input } from "@zenoxai/ui/components/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@zenoxai/ui/components/select"
import { Toaster } from "@zenoxai/ui/components/sonner"
import { Spinner } from "@zenoxai/ui/components/spinner"
import { Textarea } from "@zenoxai/ui/components/textarea"
import { ConfirmDialogHost } from "@zenoxai/ui/patterns/confirm-dialog"
import {
DataTable,
DataTableText,
actionsColumn,
createColumnHelper,
sortByRank,
useDataTable,
} from "@zenoxai/ui/patterns/data-table"
import {
FormSheet,
useFormOverlay,
type FormSheetError,
type FormSheetResult,
} from "@zenoxai/ui/patterns/form-sheet"
import { PageHeader } from "@zenoxai/ui/patterns/page-header"
import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge"
import { StatusBadge } from "@zenoxai/ui/patterns/status-badge"
import {
CHANNELS,
RULES,
SEVERITIES,
SOURCES,
connectChannel,
createRule,
isNameTaken,
setRuleEnabled,
updateRule,
type Rule,
type RuleInput,
} from "./rules"
import { useFormTiming } from "./use-form-timing"
/** The form's values: what the inputs hold, thresholds as typed text. */
type RuleValues = Omit<RuleInput, "warning" | "critical"> & {
warning: string
critical: string
}
const EMPTY: RuleValues = {
name: "",
description: "",
severity: "high",
source: "",
query: "",
warning: "",
critical: "",
channel: "",
notifyOnResolve: true,
createIncident: false,
}
const WHOLE = /^\d+$/
const TAKEN = "A rule with this name exists. Enter another name."
function valuesOf(rule: Rule): RuleValues {
return {
name: rule.name,
description: rule.description,
severity: rule.severity,
source: rule.source,
query: rule.query,
warning: String(rule.warning),
critical: String(rule.critical),
channel: rule.channel,
notifyOnResolve: rule.notifyOnResolve,
createIncident: rule.createIncident,
}
}
function inputOf(values: RuleValues): RuleInput {
return {
...values,
warning: Number(values.warning),
critical: Number(values.critical),
}
}
const labelOf = (options: { value: string; label: string }[], value: string) =>
options.find((option) => option.value === value)?.label ?? value
const helper = createColumnHelper<Rule>()
export function RecordFormBlock() {
const [rules, setRules] = React.useState<Rule[]>(() => [...RULES])
const [open, setOpen] = React.useState(false)
const [editing, setEditing] = React.useState<Rule | null>(null)
const [failure, setFailure] = React.useState<FormSheetError | null>(null)
const [result, setResult] = React.useState<FormSheetResult | null>(null)
// Where focus returns on close: the Create button or the row's menu
// button, since the menu item that opened the sheet is gone by then.
const returnTo = React.useRef<HTMLElement | null>(null)
const overlay = useFormOverlay()
const form = useForm<RuleValues>({
mode: "onSubmit",
reValidateMode: "onSubmit",
defaultValues: EMPTY,
})
const { errors, isDirty, dirtyFields } = form.formState
// Editing ignores the rule's own name.
const serverChecks = React.useMemo(
() => ({
name: async (name: string) =>
(await isNameTaken(name, editing?.id)) ? TAKEN : null,
}),
[editing]
)
const timing = useFormTiming({
form,
dependents: { warning: ["critical"] },
serverChecks,
})
function openCreate(trigger: HTMLElement) {
returnTo.current = trigger
setEditing(null)
form.reset(EMPTY)
setOpen(true)
}
function openEdit(rule: Rule) {
returnTo.current = document.querySelector<HTMLElement>(
`[data-rule-actions="${rule.id}"]`
)
setEditing(rule)
form.reset(valuesOf(rule))
setOpen(true)
}
async function toggle(rule: Rule) {
const saved = await setRuleEnabled(rule.id, !rule.enabled)
setRules((current) =>
current.map((item) => (item.id === saved.id ? saved : item))
)
toast.success(saved.enabled ? "Rule enabled" : "Rule disabled")
}
async function create(values: RuleValues) {
try {
const { rule, channelFailed } = await createRule(inputOf(values))
setRules((current) => [rule, ...current])
if (!channelFailed) {
setOpen(false)
toast.success("Rule created")
return
}
setResult({
body: (
<Alert variant="warning">
<BellOffIcon />
<AlertTitle>Rule created without notifications</AlertTitle>
<AlertDescription>
{labelOf(CHANNELS, rule.channel)} did not respond, so the rule
raises alerts but notifies no one. Connect the channel again, or
change it later from the rule.
</AlertDescription>
</Alert>
),
actions: <ResultActions onDone={() => setOpen(false)} />,
})
} catch {
setFailure({
title: "The rule was not created",
description:
"The detection service did not respond. Try again in a moment.",
})
}
}
async function save(rule: Rule, values: RuleValues) {
// Only the fields that changed go to the server.
const changes = Object.fromEntries(
Object.entries(inputOf(values)).filter(
([name]) => dirtyFields[name as keyof RuleValues]
)
) as Partial<RuleInput>
try {
const saved = await updateRule(rule.id, changes)
setRules((current) =>
current.map((item) => (item.id === saved.id ? saved : item))
)
setOpen(false)
toast.success("Rule saved")
} catch {
setFailure({
title: "The rule was not saved",
description:
"The detection service did not respond. Try again in a moment.",
})
}
}
const submit = form.handleSubmit(async (values) => {
if (!(await timing.checkServer(values))) return
// A save with no changes closes, with no request and no toast.
if (editing && !isDirty) {
setOpen(false)
return
}
setFailure(null)
await overlay.run(() => (editing ? save(editing, values) : create(values)))
})
const invalid = (name: keyof RuleValues) => ({
"aria-invalid": errors[name] ? true : undefined,
"aria-describedby": errors[name] ? `rule-${name}-error` : undefined,
})
const columns = React.useMemo(
() => [
helper.accessor("name", {
header: "Rule",
meta: { fluid: true, minWidth: 220 },
cell: (info) => (
<DataTableText
strong
secondary={info.row.original.description || undefined}
>
{info.getValue()}
</DataTableText>
),
}),
helper.accessor("severity", {
header: "Severity",
size: 112,
sortFn: sortByRank(SEVERITIES.map((severity) => severity.value)),
cell: (info) => <SeverityBadge level={info.getValue()} />,
}),
helper.accessor("source", {
header: "Source",
size: 176,
cell: (info) => (
<DataTableText>{labelOf(SOURCES, info.getValue())}</DataTableText>
),
}),
helper.accessor("enabled", {
header: "Status",
size: 112,
cell: (info) =>
info.getValue() ? (
<StatusBadge tone="success" label="Enabled" />
) : (
<StatusBadge tone="neutral" label="Disabled" />
),
}),
helper.accessor("updatedAt", {
header: "Updated",
size: 112,
cell: (info) => (
<DataTableText numeric>{info.getValue()}</DataTableText>
),
}),
actionsColumn<Rule>({
cell: (rule) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
data-rule-actions={rule.id}
aria-label={`Actions for ${rule.name}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(rule)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void toggle(rule)}>
{rule.enabled ? "Disable" : "Enable"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}),
],
// openEdit and toggle only read refs and setters.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const table = useDataTable({
data: rules,
columns,
getRowId: (rule) => rule.id,
})
return (
<div className="flex w-full flex-col gap-3">
<PageHeader
title="Detection rules"
icon={<RadarIcon />}
hint="Rules raise alerts when their query matches the data source."
actions={
<Button onClick={(event) => openCreate(event.currentTarget)}>
<PlusIcon data-icon="inline-start" />
Create rule
</Button>
}
/>
<div className="overflow-hidden rounded-lg border border-border bg-surface">
<DataTable table={table} label="Detection rules" lines={2} />
</div>
<FormSheet
overlay={overlay}
open={open}
onOpenChange={setOpen}
finalFocus={returnTo}
title={editing ? `Edit ${editing.name}` : "Create detection rule"}
description="The rule raises an alert when its query matches more events than a threshold within 10 minutes."
submitLabel={editing ? "Save changes" : "Create rule"}
dirty={isDirty}
error={failure}
result={result}
onFieldBlur={(event) => void timing.onFieldBlur(event)}
onSubmit={() => void submit()}
onClosed={() => {
form.reset(EMPTY)
setEditing(null)
setFailure(null)
setResult(null)
}}
>
<FieldGroup>
<FieldSet>
<FieldLegend>Rule</FieldLegend>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="rule-name">Name</FieldLabel>
<Input
id="rule-name"
required
autoComplete="off"
{...invalid("name")}
{...form.register("name", {
required: "Enter a name for the rule.",
onChange: () => timing.onFieldChange("name"),
})}
/>
<FieldError id="rule-name-error" errors={[errors.name]} />
</Field>
<Field>
<FieldLabel htmlFor="rule-description">
Description <FieldOptional>Optional</FieldOptional>
</FieldLabel>
<Textarea
id="rule-description"
rows={2}
{...form.register("description")}
/>
</Field>
<Controller
name="severity"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="rule-severity">Severity</FieldLabel>
<Select
items={SEVERITIES}
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger
id="rule-severity"
ref={field.ref}
onBlur={field.onBlur}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITIES.map((severity) => (
<SelectItem key={severity.value} value={severity.value}>
{severity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
</FieldSet>
<FieldSet>
<FieldLegend>Detection</FieldLegend>
<Controller
name="source"
control={form.control}
rules={{ required: "Choose a data source." }}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-source">Data source</FieldLabel>
<Select
items={SOURCES}
required
value={field.value || null}
onValueChange={(value) => {
field.onChange(value ?? "")
timing.onFieldChange("source")
}}
>
<SelectTrigger
id="rule-source"
ref={field.ref}
onBlur={field.onBlur}
className="w-full"
{...invalid("source")}
>
<SelectValue placeholder="Select a data source" />
</SelectTrigger>
<SelectContent>
{SOURCES.map((source) => (
<SelectItem key={source.value} value={source.value}>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError
id="rule-source-error"
errors={[fieldState.error]}
/>
</Field>
)}
/>
<Field data-invalid={!!errors.query}>
<FieldLabel htmlFor="rule-query">Query</FieldLabel>
<Textarea
id="rule-query"
rows={3}
required
spellCheck={false}
className="font-mono"
{...invalid("query")}
{...form.register("query", {
required: "Enter the query the rule runs.",
onChange: () => timing.onFieldChange("query"),
})}
/>
<FieldError id="rule-query-error" errors={[errors.query]} />
</Field>
<div className="grid gap-5 sm:grid-cols-2">
<Field data-invalid={!!errors.warning}>
<FieldLabel htmlFor="rule-warning">
Warning threshold
</FieldLabel>
<Input
id="rule-warning"
inputMode="numeric"
required
autoComplete="off"
{...invalid("warning")}
{...form.register("warning", {
required: "Enter a warning threshold.",
pattern: {
value: WHOLE,
message: "Enter a whole number, such as 20.",
},
onChange: () => timing.onFieldChange("warning"),
})}
/>
<FieldError id="rule-warning-error" errors={[errors.warning]} />
</Field>
<Field data-invalid={!!errors.critical}>
<FieldLabel htmlFor="rule-critical">
Critical threshold
</FieldLabel>
<Input
id="rule-critical"
inputMode="numeric"
required
autoComplete="off"
{...invalid("critical")}
{...form.register("critical", {
required: "Enter a critical threshold.",
pattern: {
value: WHOLE,
message: "Enter a whole number, such as 50.",
},
validate: (value) => {
const warning = form.getValues("warning")
return (
!WHOLE.test(warning) ||
Number(value) > Number(warning) ||
"Enter a number above the warning threshold."
)
},
onChange: () => timing.onFieldChange("critical"),
})}
/>
<FieldError
id="rule-critical-error"
errors={[errors.critical]}
/>
</Field>
</div>
</FieldSet>
<FieldSet>
<FieldLegend>Notifications</FieldLegend>
<Controller
name="channel"
control={form.control}
rules={{ required: "Choose where the rule sends its alerts." }}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="rule-channel">Channel</FieldLabel>
<Select
items={CHANNELS}
required
value={field.value || null}
onValueChange={(value) => {
field.onChange(value ?? "")
timing.onFieldChange("channel")
}}
>
<SelectTrigger
id="rule-channel"
ref={field.ref}
onBlur={field.onBlur}
className="w-full"
{...invalid("channel")}
>
<SelectValue placeholder="Select a channel" />
</SelectTrigger>
<SelectContent>
{CHANNELS.map((channel) => (
<SelectItem key={channel.value} value={channel.value}>
{channel.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError
id="rule-channel-error"
errors={[fieldState.error]}
/>
</Field>
)}
/>
<Controller
name="notifyOnResolve"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="rule-resolve"
checked={field.value}
onCheckedChange={field.onChange}
/>
<FieldLabel htmlFor="rule-resolve">
Notify when the alert resolves
</FieldLabel>
</Field>
)}
/>
<Controller
name="createIncident"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="rule-incident"
checked={field.value}
onCheckedChange={field.onChange}
/>
<FieldLabel htmlFor="rule-incident">
Open an incident for critical alerts
</FieldLabel>
</Field>
)}
/>
</FieldSet>
</FieldGroup>
</FormSheet>
{/* The app mounts these once, at its root. */}
<Toaster />
<ConfirmDialogHost />
</div>
)
}
/** The actions of the partial result: connect the channel again, or close. */
function ResultActions({ onDone }: { onDone: () => void }) {
const [retrying, setRetrying] = React.useState(false)
return (
<>
<Button variant="outline" onClick={onDone} disabled={retrying}>
Close
</Button>
<Button
disabled={retrying}
focusableWhenDisabled
aria-busy={retrying || undefined}
onClick={async () => {
setRetrying(true)
await connectChannel()
setRetrying(false)
onDone()
toast.success("Notifications set up")
}}
>
{retrying ? <Spinner data-icon="inline-start" /> : null}
Try again
</Button>
</>
)
}rules.ts147 lines
/**
* Fake detection rules and stand-ins for the API, so the block runs on its
* own. Replace them with your queries and mutations.
*/
export type Severity = "critical" | "high" | "medium" | "low"
export type Rule = {
id: string
name: string
description: string
severity: Severity
source: string
query: string
warning: number
critical: number
channel: string
notifyOnResolve: boolean
createIncident: boolean
enabled: boolean
updatedAt: string
}
/** What the form edits: every field of a rule but its id, status and dates. */
export type RuleInput = Omit<Rule, "id" | "enabled" | "updatedAt">
export const SEVERITIES: { value: Severity; label: string }[] = [
{ value: "critical", label: "Critical" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
]
export const SOURCES = [
{ value: "auth", label: "Authentication logs" },
{ value: "edr", label: "EDR events" },
{ value: "dns", label: "DNS queries" },
{ value: "firewall", label: "Firewall logs" },
]
/** PagerDuty does not answer on the first try, to show a partial result. */
export const CHANNELS = [
{ value: "slack-soc", label: "Slack, #soc-alerts" },
{ value: "email-soc", label: "Email, soc@acme.com" },
{ value: "pagerduty", label: "PagerDuty, SOC on call" },
]
export const RULES: Rule[] = [
{
id: "rule-1",
name: "Brute-force login",
description: "Many failed sign-ins for one account from one address.",
severity: "high",
source: "auth",
query: 'event.outcome:"failure" | stats count by user, source.ip',
warning: 20,
critical: 50,
channel: "slack-soc",
notifyOnResolve: true,
createIncident: false,
enabled: true,
updatedAt: "2026-10-24",
},
{
id: "rule-2",
name: "Impossible travel",
description: "Sign-ins for one account from two countries within an hour.",
severity: "medium",
source: "auth",
query: "geo.distance_km > 900 and time.delta_min < 60",
warning: 1,
critical: 3,
channel: "email-soc",
notifyOnResolve: false,
createIncident: false,
enabled: true,
updatedAt: "2026-10-20",
},
{
id: "rule-3",
name: "Ransomware file renames",
description: "",
severity: "critical",
source: "edr",
query: 'file.extension:("locked" or "crypt") | stats count by host',
warning: 50,
critical: 200,
channel: "slack-soc",
notifyOnResolve: true,
createIncident: true,
enabled: false,
updatedAt: "2026-10-12",
},
]
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** Whether another rule has this name, ignoring case and the rule being edited. */
export async function isNameTaken(name: string, exceptId?: string) {
await wait(500)
const wanted = name.trim().toLowerCase()
return RULES.some(
(rule) => rule.id !== exceptId && rule.name.toLowerCase() === wanted
)
}
let created = 0
let pagerDutyDown = true
/** Creates a rule; `channelFailed` says its channel could not be set up. */
export async function createRule(input: RuleInput) {
await wait(800)
created += 1
const rule: Rule = {
...input,
id: `rule-new-${created}`,
enabled: true,
updatedAt: "2026-10-27",
}
RULES.unshift(rule)
const channelFailed = input.channel === "pagerduty" && pagerDutyDown
pagerDutyDown = false
return { rule, channelFailed }
}
/** Sends only the fields that changed and returns the saved rule. */
export async function updateRule(id: string, changes: Partial<RuleInput>) {
await wait(800)
const index = RULES.findIndex((rule) => rule.id === id)
const rule: Rule = { ...RULES[index]!, ...changes, updatedAt: "2026-10-27" }
RULES[index] = rule
return rule
}
/** Connects a rule's channel again, after a partial result. */
export async function connectChannel() {
await wait(600)
}
/** Turns a rule on or off. Status is never part of the form. */
export async function setRuleEnabled(id: string, enabled: boolean) {
await wait(300)
const index = RULES.findIndex((rule) => rule.id === id)
const rule: Rule = { ...RULES[index]!, enabled }
RULES[index] = rule
return rule
}use-form-timing.ts123 lines
/**
* When a react-hook-form form checks its fields, as ADR 0017 sets it: a
* changed field is checked on leaving it, "required" waits for submit, a
* field with an error is checked again on every change and so are the fields
* that depend on it, and a server check runs once the field's own rules pass,
* keeping only the answer for the current value.
*
* Pair it with FormSheet, which skips the blur of a field left by pressing a
* footer button. Contract: apps/docs/e2e/record-form.spec.ts. Copy this file
* as it is; @zenoxai/platform keeps the same copy.
*/
import * as React from "react"
import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"
export type FormTimingOptions<TValues extends FieldValues> = {
/** The form, created with `mode: "onSubmit"` and `reValidateMode: "onSubmit"`. */
form: UseFormReturn<TValues>
/**
* Fields whose rule reads another field: `{ warning: ["critical"] }`
* checks critical again when warning changes, once critical shows an error.
*/
dependents?: Partial<Record<FieldPath<TValues>, FieldPath<TValues>[]>>
/**
* Checks only the server can make, such as a name that must be free. Each
* resolves to an error message, or null when the value is fine. A failed
* request counts as fine: the server checks again on save.
*/
serverChecks?: Partial<
Record<FieldPath<TValues>, (value: string) => Promise<string | null>>
>
}
export function useFormTiming<TValues extends FieldValues>({
form,
dependents,
serverChecks,
}: FormTimingOptions<TValues>) {
// Read during render so react-hook-form tracks which fields changed.
void form.formState.dirtyFields
const checks = React.useRef(new Map<string, Promise<string | null>>())
// One request per field and value: a submit during a pending check waits
// for it instead of asking again.
const serverCheck = React.useCallback(
(name: FieldPath<TValues>, value: string) => {
const check = serverChecks?.[name]
if (!check) return Promise.resolve(null)
const key = `${name}\u0000${value}`
let pending = checks.current.get(key)
if (!pending) {
pending = check(value).catch(() => {
checks.current.delete(key)
return null
})
checks.current.set(key, pending)
}
return pending
},
[serverChecks]
)
// A new set of checks, such as another record to edit, starts over.
React.useEffect(() => {
checks.current.clear()
}, [serverChecks])
/** FormSheet's `onFieldBlur`: checks a field the user changed, on leaving it. */
const onFieldBlur = React.useCallback(
async (event: React.FocusEvent<HTMLFormElement>) => {
const name = event.target.getAttribute(
"name"
) as FieldPath<TValues> | null
// A choice (a Select, a checkbox) has no named element in focus: never
// checked on blur.
if (!name) return
const state = form.getFieldState(name)
if (!state.isDirty && !state.error) return
if (!(await form.trigger(name))) return
const value = String(form.getValues(name) ?? "")
const message = await serverCheck(name, value)
if (message && String(form.getValues(name) ?? "") === value) {
form.setError(name, { type: "server", message })
}
},
[form, serverCheck]
)
/** Pass to a field's `onChange`: rechecks it and its dependents once they show an error. */
const onFieldChange = React.useCallback(
(name: FieldPath<TValues>) => {
for (const field of [name, ...(dependents?.[name] ?? [])]) {
if (form.getFieldState(field).error) void form.trigger(field)
}
},
[form, dependents]
)
/**
* Runs the server checks for the values about to be sent, waiting for any
* still pending. Resolves to false, with the error set and focused, when
* one fails.
*/
const checkServer = React.useCallback(
async (values: TValues) => {
const names = Object.keys(serverChecks ?? {}) as FieldPath<TValues>[]
for (const name of names) {
const message = await serverCheck(name, String(values[name] ?? ""))
if (message) {
form.setError(
name,
{ type: "server", message },
{ shouldFocus: true }
)
return false
}
}
return true
},
[form, serverCheck, serverChecks]
)
return { onFieldBlur, onFieldChange, checkServer }
}The reference for a form of more than five fields: create and edit a record from its list, in a Form Sheet. Copy the files under the preview into your app, replace the fake rules with your API, and keep the structure.
What it is made of
- Page Header with Create rule, the screen’s one primary action.
- Data Table of rules, with Edit, and Enable or Disable, in each row’s menu. Status is never a form field: it changes from the menu.
- One Form Sheet for create and edit, with nine fields in three
FieldSetsections. The one optional field carries the marker, and the booleans are checkboxes (Field).
The form
- The checks follow the messages guideline, through
use-form-timing.ts: a changed field is checked when the user leaves it, “required” waits for submit, and a field with an error is checked again on every change. - A server check. The name must be free: it is checked once the name is valid, on leaving the field and again on submit, and only the answer for the current value counts. Editing ignores the rule’s own name. Try “Brute-force login” in a new rule.
- A dependent field. The critical threshold must be above the warning threshold; changing the warning threshold checks the critical one again once it shows an error.
- Editing starts from the rule’s values and sends only the fields that changed. A save with no changes runs the checks and closes, with no request and no toast.
- A partial success. A rule sent to PagerDuty is created, but the channel does not answer the first time: the sheet shows what was done and offers to connect the channel again.
- Not in the URL. Create and edit stay out of the address: a close driven by the URL would skip the discard question.
Wiring it to your app
- The timing. Keep
use-form-timing.tsas it is: the block’s end-to-end tests hold it to the rules, and@zenoxai/platformcarries the same copy. Pass it the form, the dependent fields and the server checks. - The API.
rules.tsstands in for it. ReplacecreateRule,updateRule,isNameTakenandconnectChannelwith your requests, and keep their shape: a create that reports which part failed, an update that takes only the changed fields. - Texts. Every string is English here. Put them in your dictionary; the sheet’s own texts come from the Locale Provider.