diff --git a/apps/web/src/lab/entries/escalation-editor.ts b/apps/web/src/lab/entries/escalation-editor.ts new file mode 100644 index 0000000000..e2f1ac07c1 --- /dev/null +++ b/apps/web/src/lab/entries/escalation-editor.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-editor-lab" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures (`?state=`). + +export const lab = defineShellLab("Escalation chain editor", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/escalation-editor", + breadcrumbs, +}) diff --git a/apps/web/src/lab/entries/escalation.ts b/apps/web/src/lab/entries/escalation.ts index 462078241e..0c1e5e2524 100644 --- a/apps/web/src/lab/entries/escalation.ts +++ b/apps/web/src/lab/entries/escalation.ts @@ -1,7 +1,7 @@ import { defineShellLab } from "../shell-lab" import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab" -// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`). +// `/lab/escalation`: escalation chains, the rule tier picker and the incident rail (`?state=`). export const lab = defineShellLab("Escalation chains", { Model, diff --git a/apps/web/src/lab/entries/kit/buttons.ts b/apps/web/src/lab/entries/kit/buttons.ts index 8e112e463b..7654482579 100644 --- a/apps/web/src/lab/entries/kit/buttons.ts +++ b/apps/web/src/lab/entries/kit/buttons.ts @@ -31,6 +31,7 @@ const VARIANTS = [ "ghost", "destructive", "destructive-outline", + "destructive-ghost", "link", "dashed", ] as const @@ -417,16 +418,27 @@ const actionBars = (h: HtmlBuilder): ReadonlyArray => [ statusDot(h, { tone: "live" }), typography(h, { size: "sm", tone: "foreground" }, ["Unsaved changes"]), ], - actions: [button(h, { variant: "ghost", size: "sm" }, ["Discard"]), button(h, { size: "sm" }, ["Save"])], + actions: [ + button(h, { variant: "ghost", size: "sm" }, ["Discard"]), + button(h, { size: "sm" }, ["Save"]), + ], }), ]), kitRow(h, "Editor, with a notice", [ actionBar(h, { label: "Rule actions", className: "static w-full", - notice: [alert(h, { variant: "crit", size: "sm" }, [icon(h, "CircleXmarkIcon"), alertTitle(h, ["Couldn't save the rule"])])], + notice: [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the rule"]), + ]), + ], leading: [typography(h, { size: "sm", tone: "muted" }, ["Drop health checks from api-gateway"])], - actions: [button(h, { variant: "outline", size: "sm" }, ["Cancel"]), button(h, { size: "sm" }, ["Save changes"])], + actions: [ + button(h, { variant: "outline", size: "sm" }, ["Cancel"]), + button(h, { size: "sm" }, ["Save changes"]), + ], }), ]), kitRow(h, "Step flow", [ diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts new file mode 100644 index 0000000000..8a4a84110b --- /dev/null +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -0,0 +1,202 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallScheduleId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { FocusChainElement } from "../page/alerts/escalation-chain/command" +import * as ChainMessage from "../page/alerts/escalation-chain/message" +import * as ChainModel from "../page/alerts/escalation-chain/model" +import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update" +import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/view" +import { routingTabHref } from "../page/alerts/shared/escalation-chains" +import { + chainDestinations, + chainOf, + chainScheduleUuid, + populatedChains, +} from "../page/alerts/shared/escalation-test-fixtures" +import { memberId, oncallMembers, populatedSchedules } from "../page/alerts/shared/oncall-test-fixtures" +import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" +import { keepLocal } from "./local-commands" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one. + +export const LabState = Schema.Literals([ + "new", + "populated", + "inserted", + "invalid", + "delete", + "leave", + "readonly", + "long", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "populated", + ), +})) + +export const Model = Schema.Struct({ state: LabState, page: ChainModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPageMessage: { message: ChainMessage.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type PageCommands = ReadonlyArray> + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const RULES = [ + ruleRow(1, { name: "Checkout error rate" }), + ruleRow(2, { name: "Checkout p95 latency", enabled: false }), + ruleRow(3, { name: "Payments webhook failures" }), +] +const M = ChainMessage.Message + +/** The fixture destinations with the type their name says, so each draws its own mark. */ +const TYPES: ReadonlyArray = [ + ["Slack", "chat", "slack"], + ["PagerDuty", "pagerduty"], + ["Email", "email"], + ["Discord", "discord"], + ["Microsoft Teams", "microsoft-teams"], +] +const DESTINATIONS = Arr.map(chainDestinations, (row) => + Option.match( + Arr.findFirst(TYPES, ([prefix]) => Str.startsWith(prefix)(row.name)), + { + onNone: () => row, + onSome: ([, type, chatConnector]) => ({ + ...row, + type, + config_json: { + summary: row.name, + channelLabel: null, + ...(chatConnector === undefined ? undefined : { chatConnector }), + }, + }), + }, + ), +) + +/** One schedule, under the id the fixture chains page. */ +const SCHEDULES = Arr.map(Arr.take(populatedSchedules(), 1), (schedule) => ({ + ...schedule, + id: Schema.decodeSync(OnCallScheduleId)(chainScheduleUuid(1)), +})) + +/** The everyday chain, paging a schedule and a person beside its destinations. */ +const EVERYDAY = chainOf({ + n: 1, + name: "Checkout on-call", + description: "Checkout team first, then the platform lead.", + steps: [ + { destinations: [1], schedules: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], users: [memberId(0)], wait: 30 }, + ], + repeat: 2, + ruleIds: [ruleId(1), ruleId(2)], +}) +const LONG = populatedChains([])[2] + +const NO_COMMANDS: PageCommands = [] + +/** The page's local Commands: the kit's, and its own focus moves, which only touch the DOM. */ +const kept = (commands: PageCommands | undefined): PageCommands => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === FocusChainElement.name), +] + +const run = (model: ChainModel.Model, messages: ReadonlyArray) => + Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => { + const next = chainUpdate(current.model, message, CONTEXT) + return { model: next.model, commands: [...current.commands, ...kept(next.commands)] } + }) + +const pageFor = (state: LabState) => { + const isNew = state === "new" || state === "invalid" + const chain = state === "long" ? (LONG ?? EVERYDAY) : EVERYDAY + const entered = chainInit( + isNew + ? { _tag: "AlertsEscalationChainNew", search: {} } + : { + _tag: "AlertsEscalationChain", + chainId: Schema.encodeSync(EscalationChainPublicId)(chain.id), + search: {}, + }, + CONTEXT, + ).model + const base = [ + M.CompletedFetchSession({ + orgId: ORG, + grants: Policy.grantsOf([state === "readonly" ? "org:member" : "org:engineer"]), + }), + M.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + M.CompletedFetchSchedules({ result: Result.succeed(SCHEDULES) }), + M.UpdatedDestinations({ orgId: ORG, rows: DESTINATIONS }), + M.UpdatedRules({ orgId: ORG, rows: RULES }), + ] + const loaded = isNew + ? [] + : [M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) })] + const extra: ReadonlyArray = + state === "invalid" + ? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()] + : state === "inserted" + ? [M.ClickedInsertStep({ index: 1 })] + : state === "delete" + ? [M.ClickedDelete()] + : state === "leave" + ? [M.ChangedWait({ key: "s0", value: "20" }), M.ClickedCancel()] + : [] + return run(entered, [...base, ...loaded, ...extra]) +} + +export const init = (flags: Flags): Return => { + const page = pageFor(flags.state) + return { + model: { state: flags.state, page: page.model }, + commands: Command.mapMessages(page.commands, (message) => Message.GotPageMessage({ message })), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotPageMessage: ({ message: child }) => { + const next = chainUpdate(model.page, child, CONTEXT) + return { + model: { ...model, page: next.model }, + commands: Command.mapMessages(kept(next.commands), (inner) => + Message.GotPageMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model.page) }, +] + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "lab-escalation-editor", + model: model.page, + view: chainView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: ChainMessage.Message) => Message.GotPageMessage({ message }), + }) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index f710bea7d1..35a01cfe6b 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -1,13 +1,10 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import type { LoadFailure } from "@maple/views/data/load-failure" import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import * as Menu from "@maple/views/ui/menu" import * as Select from "@maple/views/ui/select" -import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" import { init as editorInit } from "../page/alerts/editor/init" @@ -33,16 +30,12 @@ import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" -// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker +// `/lab/escalation`: escalation chains in the Routing tab, a rule tier's chain picker // and the incident rail, over fixtures. `?state=` picks one. export const LabState = Schema.Literals([ "chains-empty", "chains", - "dialog-new", - "dialog-edit", - "dialog-invalid", - "dialog-error", "rule-picker", "incident-escalating", "incident-paused", @@ -99,18 +92,6 @@ const fold = ( const hubStep = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) -const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message }) - -const SAVE_FAILURE: LoadFailure = { - _tag: "@maple/http/errors/AlertValidationError", - type: "invalid_request_error", - code: "invalid_request", - title: "Invalid request", - message: "Step 2 names a destination that no longer exists.", - retryable: false, - recovery: "fix_request", -} - const hubFor = (state: LabState): Step => { const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) @@ -128,45 +109,7 @@ const hubFor = (state: LabState): Step => { result: Result.succeed(chains), }), ] - const firstStepKey = "s0" - const extra: ReadonlyArray = - state === "dialog-new" - ? [HubMessage.Message.ClickedNewChain()] - : state === "dialog-invalid" - ? [ - HubMessage.Message.ClickedNewChain(), - dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })), - dialog(ChainDialog.Message.ClickedSave()), - ] - : state === "dialog-error" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(1)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - dialog(ChainDialog.Message.ClickedSave()), - dialog( - ChainDialog.Message.CompletedSave({ - openCount: 1, - result: Result.fail(SAVE_FAILURE), - }), - ), - ] - : state === "dialog-edit" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(3)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - ] - : [] - return fold({ model: entered }, [...base, ...extra], hubStep) + return fold({ model: entered }, base, hubStep) } const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) => @@ -250,8 +193,7 @@ const incidentFor = (state: LabState): Step - state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state) +const isHub = (state: LabState) => state === "chains" || state === "chains-empty" const isIncident = (state: LabState) => Str.startsWith("incident-")(state) export const init = (flags: Flags): Return => { diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 527b3f6027..ce35e59cf5 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -89,7 +89,15 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/escalation", title: "Escalation chains", description: - "The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + "The Routing tab's escalation chains empty and populated, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + kind: "lab", + session: "none", + }, + { + path: "/lab/escalation-editor", + title: "Escalation chain editor", + description: + "The escalation chain editor page new, populated, with a step inserted, invalid, asking to delete or to leave, read-only and with a long chain (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 496064507e..829ebdadb1 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,10 @@ describe("alert rule editor, escalation chains", () => { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index e46c68bfce..f03dd78d10 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -21,7 +21,7 @@ import { destinationList, type NotifyView } from "./view-notify" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" -import { chainSummary, routingTabHref } from "../shared/escalation-chains" +import { chainSummary, newChainHref } from "../shared/escalation-chains" import { chainSelectIdOf, NO_CHAIN } from "./form-steps" import type { Tier } from "./message" import { formSelect } from "./view-select" @@ -157,7 +157,7 @@ const chainPicker = ( textLink( h, { - href: routingTabHref(), + href: newChainHref(), tone: "muted", className: "text-xs", attributes: [h.Target("_blank"), h.Rel("noopener")], diff --git a/apps/web/src/page/alerts/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts new file mode 100644 index 0000000000..fc0fa0b217 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -0,0 +1,74 @@ +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { Command, Dom } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { + deleteEscalationChain, + retrieveEscalationChain, + saveEscalationChain, +} from "../shared/escalation-chains" +import { listOnCallSchedules } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +export const FetchExistingChain = Command.define("FetchEscalationChainEditorChain", { + args: { key: Schema.String, chainId: EscalationChainId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, chainId }) => + retrieveEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +export const FetchChainEditorMembers = Command.define("FetchEscalationChainEditorMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchChainEditorSchedules = Command.define("FetchEscalationChainEditorSchedules", { + messages: [Message.CompletedFetchSchedules], + execute: listOnCallSchedules.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSchedules({ result })), + ), +}) + +export const FetchChainEditorSession = Command.define("FetchEscalationChainEditorSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveChain = Command.define("SaveEscalationChainFromEditor", { + args: { editingId: Schema.NullOr(EscalationChainId), payload: V2EscalationChainCreateParams }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveEscalationChain(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) + +export const DeleteChain = Command.define("DeleteEscalationChainFromEditor", { + args: { chainId: EscalationChainId }, + messages: [Message.CompletedDelete], + execute: ({ chainId }) => + deleteEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDelete({ result })), + ), +}) + +/** Moves focus after a step edit, to an element by id once it is on the page. */ +export const FocusChainElement = Command.define("FocusEscalationChainElement", { + args: { id: Schema.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`).pipe(Effect.ignore, Effect.as(Message.CompletedFocus())), +}) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts new file mode 100644 index 0000000000..b195df009c --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -0,0 +1,510 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Option, Result, Schema, Array as Arr, String as Str } from "effect" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import type { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import { + click, + Command, + expect as expectView, + given, + Mount, + role, + scene, + text, + within, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { CHAIN_IN_USE_CODE, CHAIN_NOT_FOUND_CODE } from "../shared/escalation-chains" +import { + chainDestinations, + chainDestinationUuid, + chainUuid, + populatedChains, +} from "../shared/escalation-test-fixtures" +import { memberId, mixedTargetChain, oncallMembers, populatedSchedules } from "../shared/oncall-test-fixtures" +import { ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusChainElement } from "./command" +import { payloadOf } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { init, insertButtonId, isBlockingNavigation, menuIdOf, removeTargetButtonId, update } from "./update" +import { chainTitle, view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const keys = (model: Model) => Arr.map(model.form.steps, (step) => step.key) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) +const chainPublicId = (n: number) => + Schema.encodeSync(EscalationChainPublicId)(Schema.decodeSync(EscalationChainId)(chainUuid(n))) + +const failureOf = (code: string, message: string): LoadFailure => ({ + _tag: "@maple/http/errors/EscalationChainError", + type: code === CHAIN_IN_USE_CODE ? "conflict_error" : "not_found_error", + code, + title: "Escalation chain", + message, + retryable: false, + recovery: "fix_request", +}) +const IN_USE = failureOf(CHAIN_IN_USE_CODE, "The escalation chain is used by one or more alert rules.") +const GONE = failureOf(CHAIN_NOT_FOUND_CODE, "Escalation chain not found.") + +const pick = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "targets", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) +const act = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "actions", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) + +const entered = (route: Parameters[0], grants = ENGINEER) => + run(init(route, context).model, [ + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSchedules({ result: Result.succeed(populatedSchedules()) }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: RULES }), + ]) +const newChain = (grants = ENGINEER) => entered({ _tag: "AlertsEscalationChainNew", search: {} }, grants) +const opening = (n: number, grants = ENGINEER) => + entered({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }, grants) +const loaded = (model: Model, chain = CHAINS[0]) => { + expect(chain).toBeDefined() + return chain === undefined + ? model + : run(model, [ + Message.CompletedFetchExisting({ + key: model.existing.key ?? "", + result: Result.succeed(chain), + }), + ]) +} +const editing = (n: number, grants = ENGINEER) => loaded(opening(n, grants), CHAINS[n - 1]) + +describe("escalation chain editor", () => { + test("a new chain reads as a sentence from the start card to the end card", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + expectView(text("New escalation chain")).toExist(), + expectView(text("When an alert escalates to this chain")).toExist(), + expectView(text("Notify")).toExist(), + // Nothing follows the last step of a chain that does not repeat. + expectView(text("minutes, then stop")).toExist(), + expectView(text("If nobody acknowledges, repeat the chain")).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), + expectView(role("button", { name: "Create chain" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(run(newChain(), [Message.ChangedRepeatCount({ value: "2" })])), + expectView(text("minutes for an acknowledgement, then repeat")).toExist(), + ) + }) + + test("builds a chain: add steps, insert between, reorder, remove, then save back to Routing", () => { + const first = run(newChain(), [ + Message.ChangedName({ value: "Checkout on-call" }), + pick("s0", `destination:${chainDestinationUuid(1)}`), + Message.ClickedInsertStep({ index: 1 }), + pick("s1", `destination:${chainDestinationUuid(2)}`), + Message.ChangedWait({ key: "s0", value: "5" }), + ]) + expect(keys(first)).toEqual(["s0", "s1"]) + // The + between the two cards puts a new step in the middle. + scene( + { update: pageUpdate, view }, + given(first), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("minutes for an acknowledgement")).toExist(), + click(role("button", { name: "Add a step after step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), + expectView(role("button", { name: "Actions for step 3" })).toExist(), + ) + const middle = pageUpdate(first, Message.ClickedInsertStep({ index: 1 })).model + expect(keys(middle)).toEqual(["s0", "s2", "s1"]) + const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) + expect(keys(reordered)).toEqual(["s1", "s0"]) + const saved = pageUpdate(reordered, Message.ClickedSave()) + expect(names(saved.commands)).toEqual(["SaveEscalationChainFromEditor"]) + expect( + Option.map(payloadOf(reordered.form), (payload) => Arr.map(payload.steps, (s) => s.wait_minutes)), + ).toEqual(Option.some([10, 5])) + const chain = CHAINS[0] + expect(chain).toBeDefined() + if (chain === undefined) return + const done = pageUpdate(saved.model, Message.CompletedSave({ result: Result.succeed(chain) })) + expect(done.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: "/alerts/routing", + toast: { title: "Escalation chain created" }, + }) + // The saved form is not an unsaved edit: leaving does not ask. + expect(isBlockingNavigation(done.model)).toBe(false) + }) + + test("a step edit moves focus to where the work continues", () => { + const two = pageUpdate(newChain(), Message.ClickedInsertStep({ index: 1 })) + // A new step: its Add button, so its targets come next. + expect(two.commands).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: triggerId(menuIdOf("targets", "s1")) } }, + ]) + const copied = pageUpdate(two.model, act("s0", "duplicate")) + expect(Arr.last(copied.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("targets", "s2")) } }), + ) + // A removed step: the step before it, or the + that now leads the chain. + const removedLast = pageUpdate(two.model, act("s1", "remove")) + expect(Arr.last(removedLast.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("actions", "s0")) } }), + ) + const removedFirst = pageUpdate(two.model, act("s0", "remove")) + expect(Arr.last(removedFirst.commands ?? [])).toMatchObject( + Option.some({ args: { id: insertButtonId(0) } }), + ) + }) + + test("removing a target moves focus to the chip that takes its place, or to the step's Add button", () => { + const slack = `destination:${chainDestinationUuid(1)}` + const pagerDuty = `destination:${chainDestinationUuid(2)}` + const both = run(newChain(), [pick("s0", slack), pick("s0", pagerDuty)]) + const focused = (model: Model, target: string) => + pageUpdate(model, Message.ClickedRemoveTarget({ key: "s0", target })).commands + // The first of two: the one after it, which now leads the step. + expect(focused(both, slack)).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: removeTargetButtonId("s0", pagerDuty) } }, + ]) + // The last one, and the only one: the Add button that follows the chips. + const add = { + name: "FocusEscalationChainElement", + args: { id: triggerId(menuIdOf("targets", "s0")) }, + } + expect(focused(both, pagerDuty)).toMatchObject([add]) + const one = pageUpdate(both, Message.ClickedRemoveTarget({ key: "s0", target: slack })).model + expect(focused(one, pagerDuty)).toMatchObject([add]) + // The chip the focus is sent to is on the page under that id. + scene( + { update: pageUpdate, view }, + given(both), + click(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), + expectView(role("button", { name: /^Stop notifying .+ on step 1$/u })).toHaveAttr( + "id", + removeTargetButtonId("s0", pagerDuty), + ), + ) + // A target that is not on the step, and a viewer who cannot edit: nothing moves. + expect(focused(both, "destination:missing") ?? []).toEqual([]) + expect( + focused(run(both, [Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })]), slack) ?? [], + ).toEqual([]) + }) + + test("saving with missing fields shows each problem and sends nothing", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + click(role("button", { name: "Create chain" })), + expectView(text("Name the chain")).toExist(), + expectView(text("Pick at least one destination, person or schedule")).toExist(), + ) + expect(names(pageUpdate(newChain(), Message.ClickedSave()).commands)).toEqual([]) + }) + + test("an existing chain fills the form, previews itself and lists the rules that use it", () => { + const model = editing(1) + expect(model.form.name).toBe("Checkout on-call") + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("link", { name: "Checkout errors" })).toExist(), + expectView( + text( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ), + ).toExist(), + expectView(role("button", { name: "Save chain" })).toExist(), + ) + const saving = pageUpdate( + run(model, [Message.ChangedName({ value: "Checkout" })]), + Message.ClickedSave(), + ) + expect(names(saving.commands)).toEqual(["SaveEscalationChainFromEditor"]) + const chain = CHAINS[0] + expect(chain).toBeDefined() + if (chain === undefined) return + // Back to the list, which reads the saved name and rules fresh. + expect( + pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain saved" } }) + }) + + test("people and schedules read by name, with who is on call and who has left", () => { + const schedule = populatedSchedules()[0] + expect(schedule).toBeDefined() + const chain = mixedTargetChain(chainDestinationUuid(1)) + const model = loaded(opening(1), chain) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text(schedule?.name ?? "")).toExist(), + expectView(text(/ on call$/u)).toExist(), + expectView(text(oncallMembers[1]?.name ?? "")).toExist(), + ) + // Someone removed from the org since the chain was saved. + scene( + { update: pageUpdate, view }, + given({ + ...model, + members: Arr.filter(model.members ?? [], (member) => member.userId !== memberId(0)), + }), + expectView(text("Unavailable member")).toExist(), + ) + }) + + test("the target picker says what is still loading, and where to make a destination", () => { + const menuId = menuIdOf("targets", "s0") + const open = (model: Model): Model => ({ + ...model, + menus: { [menuId]: Menu.init(menuId, { isOpen: true }) }, + }) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: null, schedules: null })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("Loading destinations")).toExist(), + expectView(text("Loading schedules")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: [], members: [], schedules: [] })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text(/No destinations yet/u)).toExist(), + expectView(role("link", { name: "Create a destination" })).toHaveAttr("href", "/alerts/routing"), + ) + // A failed read is said, so an empty People group is not mistaken for an org of one. + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), members: null, isMembersFailed: true })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("People could not be loaded")).toExist(), + ) + }) + + test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { + const asked = pageUpdate(editing(1), Message.ClickedDelete()).model + expect(asked.deleteDialog.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(text("Checkout p95")).toExist(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), + ) + }) + + test("a chain one rule starts is refused in the singular", () => { + const [first] = CHAINS + expect(first).toBeDefined() + if (first === undefined) return + const single = loaded(opening(1), { ...first, rule_ids: Arr.take(first.rule_ids, 1) }) + scene( + { update: pageUpdate, view }, + given(pageUpdate(single, Message.ClickedDelete()).model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/This rule would stop escalating/u)).toExist(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), + ) + }) + + test("notes are saved with the chain, and refused past 500 characters", () => { + const noted = run(editing(1), [Message.ChangedDescription({ value: "Rollback notes" })]) + expect(Option.map(payloadOf(noted.form), (payload) => payload.description)).toEqual( + Option.some("Rollback notes"), + ) + const tooLong = run(editing(1), [ + Message.ChangedDescription({ value: Str.repeat(501)("x") }), + Message.ClickedSave(), + ]) + expect(payloadOf(tooLong.form)).toEqual(Option.none()) + scene( + { update: pageUpdate, view }, + given(tooLong), + expectView(text("Keep the notes to 500 characters")).toExist(), + ) + }) + + test("a delete the API refuses reads the chain again, so the dialog names the rule that took it", () => { + const asked = run(editing(2), [Message.ClickedDelete(), Message.ClickedConfirmDelete()]) + expect(asked.isDeleting).toBe(true) + const refused = pageUpdate(asked, Message.CompletedDelete({ result: Result.fail(IN_USE) })) + expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) + expect(names(refused.commands)).toEqual(["FetchEscalationChainEditorChain"]) + const chain = CHAINS[1] + expect(chain).toBeDefined() + if (chain === undefined) return + const edited = run(refused.model, [Message.ChangedName({ value: "Payments" })]) + const reread = pageUpdate( + edited, + Message.CompletedFetchExisting({ + key: edited.existing.key ?? "", + result: Result.succeed({ ...chain, rule_ids: CHAINS[0]?.rule_ids ?? [] }), + }), + ).model + // The re-read brings the rules, never the form: an edit in progress stays. + expect(reread.form.name).toBe("Payments") + scene( + { update: pageUpdate, view }, + given(reread), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Used by 2 rules")).toExist(), + expectView(text(/These rules would stop escalating/u)).toExist(), + ) + }) + + test("an unused chain deletes and goes back to Routing", () => { + const unused = editing(3) + const confirmed = pageUpdate( + pageUpdate(unused, Message.ClickedDelete()).model, + Message.ClickedConfirmDelete(), + ) + expect(names(confirmed.commands)).toEqual(["DeleteEscalationChainFromEditor"]) + const done = pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.void })) + expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/routing" }) + // Deleted elsewhere first: the same outcome. + expect( + pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.fail(GONE) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain deleted" } }) + }) + + test("Cancel leaves at once with nothing to lose, and asks first over unsaved edits", () => { + const clean = editing(1) + expect(isBlockingNavigation(clean)).toBe(false) + expect(pageUpdate(clean, Message.ClickedCancel()).outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: "/alerts/routing", + }) + const dirty = run(clean, [Message.ChangedWait({ key: "s0", value: "20" })]) + expect(isBlockingNavigation(dirty)).toBe(true) + // An edit put back is no edit. + expect(isBlockingNavigation(run(dirty, [Message.ChangedWait({ key: "s0", value: "10" })]))).toBe( + false, + ) + const asked = pageUpdate(dirty, Message.ClickedCancel()) + expect(asked.outMessage).toBeUndefined() + expect(asked.model.leaveDialog.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(asked.model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Unsaved changes")).toExist(), + expectView(role("button", { name: "Stay" })).toExist(), + expectView(role("button", { name: "Discard changes" })).toExist(), + ) + const discarded = pageUpdate(asked.model, Message.ClickedDiscard()) + expect(discarded.outMessage).toMatchObject({ url: "/alerts/routing" }) + expect(isBlockingNavigation(discarded.model)).toBe(false) + }) + + test("a chain deleted elsewhere reads as not found, with the way back and nothing to retry", () => { + const opened = opening(1) + const gone = pageUpdate( + opened, + Message.CompletedFetchExisting({ key: opened.existing.key ?? "", result: Result.fail(GONE) }), + ).model + scene( + { update: pageUpdate, view }, + given(gone), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("link", { name: "Back to routing" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Try again" })).not.toExist(), + ) + // Deleted while it was being edited: the save says so. + const saving = pageUpdate(editing(1), Message.ClickedSave()).model + scene( + { update: pageUpdate, view }, + given(pageUpdate(saving, Message.CompletedSave({ result: Result.fail(GONE) })).model), + expectView(text(/This chain no longer exists/u)).toExist(), + ) + }) + + test("a viewer who cannot manage alerts reads the chain and its rules, and can change nothing", () => { + const model = editing(1, MEMBER) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Only org admins and engineers can change escalation chains")).toExist(), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("textbox", { name: "Name" })).toBeDisabled(), + expectView(role("button", { name: "Save chain" })).not.toExist(), + expectView(role("button", { name: "Delete chain" })).not.toExist(), + expectView(role("button", { name: "Actions for step 1" })).not.toExist(), + expectView(role("button", { name: "Add a step after step 1" })).not.toExist(), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), + expectView(role("link", { name: "Back to routing" })).toExist(), + ) + // The update holds the same line as the view. + expect(run(model, [Message.ChangedName({ value: "Mine now" })]).form.name).toBe("Checkout on-call") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + expect(pageUpdate(model, Message.ClickedDelete()).model.deleteDialog.isOpen).toBe(false) + // A new chain has nothing to read: the viewer learns who can make one. + scene( + { update: pageUpdate, view }, + given(newChain(MEMBER)), + expectView(text("Only org admins and engineers can create escalation chains")).toExist(), + ) + }) + + test("nothing can be saved or changed until the session says who the viewer is", () => { + const opened = init({ _tag: "AlertsEscalationChainNew", search: {} }, context).model + scene( + { update: pageUpdate, view }, + given(opened), + expectView(role("button", { name: "Create chain" })).not.toExist(), + ) + }) + + test("a link with a malformed chain id says the chain was not found, under the heading a deleted one gets", () => { + Arr.forEach(["nope", "esc_notAChainId"], (chainId) => { + const bad = entered({ _tag: "AlertsEscalationChain", chainId, search: {} }) + scene( + { update: pageUpdate, view }, + given(bad), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("heading", { name: "Escalation chain" })).toExist(), + expectView(role("heading", { name: "New escalation chain" })).not.toExist(), + ) + // The breadcrumb's last entry is the same title. + expect(chainTitle(bad)).toBe("Escalation chain") + }) + // The new-chain page keeps its own name. + expect(chainTitle(newChain())).toBe("New escalation chain") + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts new file mode 100644 index 0000000000..c30f3098dc --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -0,0 +1,135 @@ +import { Option, Array as Arr, Schema } from "effect" +import { AlertDestinationId, MAX_ESCALATION_STEPS } from "@maple/domain/http" +import { describe, expect, it } from "vitest" +import { chainSummary } from "../shared/escalation-chains" +import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { mixedTargetChain } from "../shared/oncall-test-fixtures" +import { + addTarget, + duplicateStep, + emptyForm, + type Form, + formFromChain, + formIssues, + insertStep, + isSameChain, + moveStep, + payloadOf, + previewOf, + removeStep, + repeatError, + waitError, +} from "./form" + +const asDestination = Schema.decodeSync(AlertDestinationId) +const destination = (n: number) => ({ + type: "destination" as const, + destination_id: asDestination(chainDestinationUuid(n)), +}) +const keys = (form: Form) => Arr.map(form.steps, (step) => step.key) + +const loaded = (): Form => + formFromChain( + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ) + +describe("escalation chain form", () => { + it("an edited chain round-trips to the payload it was read from", () => { + expect(payloadOf(loaded())).toEqual( + Option.some({ + name: "Checkout on-call", + description: null, + steps: [ + { targets: [destination(1)], wait_minutes: 10 }, + { targets: [destination(2)], wait_minutes: 15 }, + ], + repeat_count: 1, + }), + ) + }) + + it("keeps a chain's people and schedules through a load", () => { + const chain = mixedTargetChain(chainDestinationUuid(1)) + expect(Option.map(payloadOf(formFromChain(chain)), (payload) => payload.steps)).toEqual( + Option.some(chain.steps), + ) + }) + + it("a new form has no payload until it is named and its step pages someone", () => { + expect(payloadOf(emptyForm)).toEqual(Option.none()) + expect(formIssues(emptyForm)).toEqual([ + "Name the chain", + "Pick at least one destination, person or schedule", + ]) + const ready = addTarget({ ...emptyForm, name: "On-call" }, "s0", destination(1)) + expect(Option.isSome(payloadOf(ready))).toBe(true) + }) + + it("rejects waits and repeats outside the API's limits", () => { + expect(Option.isSome(waitError("0"))).toBe(true) + expect(Option.isSome(waitError("2000"))).toBe(true) + expect(Option.isSome(waitError(""))).toBe(true) + expect(waitError("10")).toEqual(Option.none()) + expect(Option.isSome(repeatError("-1"))).toBe(true) + expect(repeatError("0")).toEqual(Option.none()) + }) + + it("inserts a step where the + was pressed, each with its own key", () => { + const form = loaded() + const between = insertStep(form, 1) + expect(keys(between)).toEqual(["s0", "s2", "s1"]) + expect(keys(insertStep(between, 0))).toEqual(["s3", "s0", "s2", "s1"]) + expect(keys(insertStep(form, 2))).toEqual(["s0", "s1", "s2"]) + }) + + it("stops adding steps at the API's limit", () => { + const full = Arr.reduce(Arr.range(1, MAX_ESCALATION_STEPS + 2), emptyForm, (form) => + insertStep(form, form.steps.length), + ) + expect(full.steps.length).toBe(MAX_ESCALATION_STEPS) + expect(duplicateStep(full, "s0").steps.length).toBe(MAX_ESCALATION_STEPS) + }) + + it("moves, duplicates and removes steps, keeping at least one", () => { + const form = loaded() + expect(keys(moveStep(form, "s1", "up"))).toEqual(["s1", "s0"]) + expect(keys(moveStep(form, "s0", "up"))).toEqual(["s0", "s1"]) + const copied = duplicateStep(form, "s0") + expect(keys(copied)).toEqual(["s0", "s2", "s1"]) + expect(copied.steps[1]?.targets).toEqual(form.steps[0]?.targets) + expect(keys(removeStep(form, "s0"))).toEqual(["s1"]) + expect(keys(removeStep(removeStep(form, "s0"), "s1"))).toEqual(["s1"]) + }) + + it("adds a target to a step once", () => { + const twice = addTarget(addTarget(emptyForm, "s0", destination(1)), "s0", destination(1)) + expect(twice.steps[0]?.targets).toEqual([destination(1)]) + }) + + it("previews the chain as it reads elsewhere, skipping steps that page no one yet", () => { + const names = (id: string) => + id === chainDestinationUuid(1) ? "Slack #alerts-api" : "PagerDuty Checkout" + const form = insertStep({ ...loaded(), repeatCount: "2" }, 1) + expect(chainSummary(previewOf(form), names)).toBe( + "Slack #alerts-api, then PagerDuty Checkout after 10m; repeats twice", + ) + expect(previewOf(emptyForm).steps).toEqual([]) + }) + + it("reads an edit that was undone as the chain it started from", () => { + const form = loaded() + expect(isSameChain(form, form)).toBe(true) + expect(isSameChain(removeStep(insertStep(form, 1), "s2"), form)).toBe(true) + expect(isSameChain({ ...form, name: "Checkout on-call (EU)" }, form)).toBe(false) + expect(isSameChain(moveStep(form, "s1", "up"), form)).toBe(false) + expect(isSameChain(addTarget(form, "s0", destination(3)), form)).toBe(false) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts new file mode 100644 index 0000000000..794f656ee0 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -0,0 +1,297 @@ +import { + AlertDestinationId, + EscalationChainDescription, + EscalationChainName, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, + OnCallScheduleId, + UserId, +} from "@maple/domain/http" +import { + type V2EscalationChain, + type V2EscalationChainCreateParams, + type V2EscalationStep, + V2EscalationTarget, +} from "@maple/domain/http/v2" +import { Equal, Option, Schema, Array as Arr, String as Str } from "effect" +import type { ChainOutline } from "../shared/escalation-chains" + +// The escalation chain editor's form: its drafts, the API's limits as field errors, and step edits. + +/** One step as the form holds it; `key` keeps a step's card (and its menus) across reorders. */ +export const StepDraft = Schema.Struct({ + key: Schema.String, + /** Destinations, people and schedules, in the order they were added; all kept on load. */ + targets: Schema.Array(V2EscalationTarget), + waitMinutes: Schema.String, +}) +export type StepDraft = typeof StepDraft.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + steps: Schema.Array(StepDraft), + repeatCount: Schema.String, + nextKey: Schema.Number, +}) +export type Form = typeof Form.Type + +export const DEFAULT_WAIT_MINUTES = "10" + +export const emptyStep = (key: number): StepDraft => ({ + key: `s${key}`, + targets: [], + waitMinutes: DEFAULT_WAIT_MINUTES, +}) + +export const emptyForm: Form = { + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, +} + +export const formFromChain = (chain: V2EscalationChain): Form => ({ + name: chain.name, + description: chain.description ?? "", + steps: Arr.map(chain.steps, (step, index) => ({ + key: `s${index}`, + targets: step.targets, + waitMinutes: String(step.wait_minutes), + })), + repeatCount: String(chain.repeat_count), + nextKey: chain.steps.length, +}) + +const contentOf = (form: Form) => ({ + name: form.name, + description: form.description, + repeatCount: form.repeatCount, + steps: Arr.map(form.steps, (step) => ({ targets: step.targets, waitMinutes: step.waitMinutes })), +}) + +/** Two forms describe the same chain: step keys aside, nothing a save sends differs. */ +export const isSameChain = (a: Form, b: Form): boolean => Equal.equals(contentOf(a), contentOf(b)) + +// VALIDATION: the same limits the API's schemas hold. + +const isName = Schema.is(EscalationChainName) +const isDescription = Schema.is(EscalationChainDescription) +const isWait = Schema.is(EscalationWaitMinutes) +const isRepeat = Schema.is(EscalationRepeatCount) + +export const numberOf = (raw: string): number | null => + Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the chain") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) ? Option.none() : Option.some("Keep the notes to 500 characters") + +export const waitError = (raw: string): Option.Option => + isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) + +export const repeatError = (raw: string): Option.Option => + isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) + +export const targetsError = (targets: ReadonlyArray): Option.Option => + targets.length === 0 + ? Option.some("Pick at least one destination, person or schedule") + : targets.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) + : Option.none() + +export const stepsError = (steps: ReadonlyArray): Option.Option => + steps.length === 0 + ? Option.some("Add at least one step") + : steps.length > MAX_ESCALATION_STEPS + ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) + : Option.none() + +/** Every field's problem, in form order, for the action bar. */ +export const formIssues = (form: Form): ReadonlyArray => + Arr.getSomes([ + nameError(form.name), + descriptionError(form.description), + stepsError(form.steps), + ...Arr.flatMap(form.steps, (step) => [targetsError(step.targets), waitError(step.waitMinutes)]), + repeatError(form.repeatCount), + ]) + +/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ +export const targetKey = (target: V2EscalationTarget): string => { + switch (target.type) { + case "destination": + return `destination:${target.destination_id}` + case "user": + return `user:${target.user_id}` + case "schedule": + return `schedule:${target.schedule_id}` + } +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) +const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) + +/** The target a menu value names, if it is well formed. */ +export const targetOfKey = (key: string): Option.Option => { + const [kind, ...rest] = Str.split(":")(key) + const id = Arr.join(rest, ":") + switch (kind) { + case "destination": + return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ + type: "destination", + destination_id, + })) + case "user": + return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) + case "schedule": + return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ + type: "schedule", + schedule_id, + })) + default: + return Option.none() + } +} + +const stepOf = (step: StepDraft): Option.Option => { + const targets = step.targets + const minutes = numberOf(step.waitMinutes) + return Arr.isReadonlyArrayNonEmpty(targets) && + Option.isNone(targetsError(targets)) && + minutes !== null && + isWait(minutes) + ? Option.some({ targets, wait_minutes: minutes }) + : Option.none() +} + +/** The create payload the form describes, or none while any field is wrong. */ +export const payloadOf = (form: Form): Option.Option => { + const steps = Option.all(Arr.map(form.steps, stepOf)) + const repeat = numberOf(form.repeatCount) + if ( + Option.isSome(nameError(form.name)) || + Option.isSome(descriptionError(form.description)) || + Option.isSome(stepsError(form.steps)) || + repeat === null || + !isRepeat(repeat) + ) + return Option.none() + return Option.flatMap(steps, (all) => + Arr.isReadonlyArrayNonEmpty(all) + ? Option.some({ + name: Str.trim(form.name), + description: Str.isEmpty(Str.trim(form.description)) ? null : Str.trim(form.description), + steps: all, + repeat_count: repeat, + }) + : Option.none(), + ) +} + +/** The chain the form describes so far, for the live preview: only steps with targets count. */ +export const previewOf = (form: Form): ChainOutline => ({ + steps: Arr.map( + Arr.filter(form.steps, (step) => step.targets.length > 0), + (step) => ({ + targets: step.targets, + wait_minutes: Option.isNone(waitError(step.waitMinutes)) ? Number(step.waitMinutes) : 0, + }), + ), + repeat_count: Option.isNone(repeatError(form.repeatCount)) ? Number(form.repeatCount) : 0, +}) + +// STEP EDITS + +export const Direction = Schema.Literals(["up", "down"]) +export type Direction = typeof Direction.Type + +export const canAddStep = (form: Form): boolean => form.steps.length < MAX_ESCALATION_STEPS + +/** A new empty step at `index` (0 puts it first, `steps.length` last). */ +export const insertStep = (form: Form, index: number): Form => + canAddStep(form) + ? { + ...form, + steps: Arr.insertAt( + form.steps, + Math.max(0, Math.min(index, form.steps.length)), + emptyStep(form.nextKey), + ).pipe(Option.getOrElse(() => form.steps)), + nextKey: form.nextKey + 1, + } + : form + +/** A copy of the step right after it, with its own key. */ +export const duplicateStep = (form: Form, key: string): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (index) => { + const source = form.steps[index] + if (source === undefined || !canAddStep(form)) return form + const copy: StepDraft = { ...source, key: `s${form.nextKey}` } + return { + ...form, + steps: Option.getOrElse(Arr.insertAt(form.steps, index + 1, copy), () => form.steps), + nextKey: form.nextKey + 1, + } + }, + }, + ) + +/** Removes a step; the last one stays, since a chain needs at least one. */ +export const removeStep = (form: Form, key: string): Form => + form.steps.length <= 1 ? form : { ...form, steps: Arr.filter(form.steps, (step) => step.key !== key) } + +/** Swaps a step with its neighbour; at either end the steps stay as they are. */ +export const moveStep = (form: Form, key: string, direction: Direction): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (from) => { + const to = direction === "up" ? from - 1 : from + 1 + const moving = form.steps[from] + const other = form.steps[to] + if (moving === undefined || other === undefined) return form + return { + ...form, + steps: Arr.map(form.steps, (step, index) => + index === from ? other : index === to ? moving : step, + ), + } + }, + }, + ) + +export const editStep = (form: Form, key: string, edit: (step: StepDraft) => StepDraft): Form => ({ + ...form, + steps: Arr.map(form.steps, (step) => (step.key === key ? edit(step) : step)), +}) + +export const addTarget = (form: Form, key: string, target: V2EscalationTarget): Form => + editStep(form, key, (step) => + Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) + ? step + : { ...step, targets: [...step.targets, target] }, + ) + +export const removeTarget = (form: Form, key: string, target: string): Form => + editStep(form, key, (step) => ({ + ...step, + targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), + })) diff --git a/apps/web/src/page/alerts/escalation-chain/index.ts b/apps/web/src/page/alerts/escalation-chain/index.ts new file mode 100644 index 0000000000..6ae2fa11fc --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -0,0 +1,28 @@ +import { definePage } from "../../contract" +import { routingTabHref } from "../shared/escalation-chains" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { askToLeave, init, isBlockingNavigation, update } from "./update" +import { chainTitle, view } from "./view" + +/** `/alerts/escalation-chains/new` and `/:chainId`: one instance per chain, or the new one. */ +export const page = definePage( + "AlertsEscalationChain", + { Model, Message }, + { + routes: ["AlertsEscalationChainNew", "AlertsEscalationChain"], + key: (route) => (route._tag === "AlertsEscalationChain" ? route.chainId : "new"), + init, + update, + view, + subscriptions, + isBlockingNavigation, + blockedNavigation: (model, url) => askToLeave(model, url), + breadcrumbs: (model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model) }, + ], + }, +) diff --git a/apps/web/src/page/alerts/escalation-chain/message.ts b/apps/web/src/page/alerts/escalation-chain/message.ts new file mode 100644 index 0000000000..61cc0d7c0a --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -0,0 +1,46 @@ +import * as Policy from "@maple/domain/policy" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" + +const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const MenuKind = Schema.Literals(["targets", "actions"]) +export type MenuKind = typeof MenuKind.Type + +export const Message = defineMessageUnion({ + CompletedFetchExisting: { key: Schema.String, result: outcome(V2EscalationChain) }, + ClickedRetryExisting: {}, + UpdatedDestinations: rows(AlertDestinationRowSchema), + UpdatedRules: rows(AlertRuleRowSchema), + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSchedules: { result: outcome(Schema.Array(V2OnCallSchedule)) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + ChangedRepeatCount: { value: Schema.String }, + ChangedWait: { key: Schema.String, value: Schema.String }, + /** The "+" between two cards: a new step at `index`. */ + ClickedInsertStep: { index: Schema.Number }, + /** A step's target picker or its actions menu. */ + GotMenuMessage: { kind: MenuKind, key: Schema.String, message: Menu.Message }, + ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2EscalationChain) }, + GotDeleteDialogMessage: { message: Dialog.Message }, + ClickedDelete: {}, + ClickedConfirmDelete: {}, + CompletedDelete: { result: outcome(Schema.Void) }, + ClickedCancel: {}, + GotLeaveDialogMessage: { message: Dialog.Message }, + ClickedDiscard: {}, + FailedLoadProviderLogo: { type: Schema.String }, + CompletedFocus: {}, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts new file mode 100644 index 0000000000..b805a30ab6 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -0,0 +1,53 @@ +import * as Policy from "@maple/domain/policy" +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" +import { Form } from "./form" + +// `/alerts/escalation-chains/new` and `/alerts/escalation-chains/:chainId`: the chain editor. + +export const Model = Schema.Struct({ + /** The chain being edited; `null` creates. */ + editingId: Schema.NullOr(EscalationChainId), + /** The URL named a chain id that does not decode. */ + isBadId: Schema.Boolean, + /** The edited chain's read; the form fills from it once. */ + existing: KeyedSchema(V2EscalationChain, LoadFailure), + form: Form, + /** The form as it was opened or last saved: `form` differing from it is an unsaved edit. */ + baseline: Form, + destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), + rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), + /** `null` until the members load, and after a failed read (`isMembersFailed`). */ + members: Schema.NullOr(Schema.Array(OrgMember)), + isMembersFailed: Schema.Boolean, + /** `null` until the schedules load, and after a failed read (`isSchedulesFailed`). */ + schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), + isSchedulesFailed: Schema.Boolean, + /** Destination types whose brand image failed to load: they draw the monogram. */ + brokenLogos: Schema.Array(Schema.String), + /** Each step's target picker and actions menu, by menu id. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: each field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveFailure: Schema.NullOr(LoadFailure), + deleteDialog: Dialog.Model, + isDeleting: Schema.Boolean, + deleteError: Schema.NullOr(Schema.String), + /** "Unsaved changes": a navigation held while the form has edits, and where it was going. */ + leaveDialog: Dialog.Model, + pendingLeaveUrl: Schema.NullOr(Schema.String), + grants: Schema.Array(Policy.PermissionName), + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The viewer may change chains (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/escalation-chain/subscription.ts b/apps/web/src/page/alerts/escalation-chain/subscription.ts new file mode 100644 index 0000000000..dab0329dc8 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/subscription.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import type { PageSubscriptionInput } from "../../contract" +import { destinationRows, ruleRows } from "../shared/collections" +import { Message } from "./message" +import type { Model } from "./model" + +const byOrg = { orgId: Schema.String } + +/** The synced destinations steps pick from, and the rules that name the chain ("Used by"). */ +export const subscriptions = Subscription.make, Message, Resources>()( + (entry) => ({ + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + }), +) diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts new file mode 100644 index 0000000000..2f2ca2143a --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -0,0 +1,420 @@ +import { Option, Result, Array as Arr } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { + CHAIN_IN_USE_CODE, + CHAIN_NOT_FOUND_CODE, + chainIdFromUrl, + routingTabHref, +} from "../shared/escalation-chains" +import { + DeleteChain, + FetchChainEditorMembers, + FetchChainEditorSchedules, + FetchChainEditorSession, + FetchExistingChain, + FocusChainElement, + SaveChain, +} from "./command" +import { + addTarget, + duplicateStep, + editStep, + emptyForm, + type Form, + formFromChain, + insertStep, + isSameChain, + moveStep, + payloadOf, + removeStep, + removeTarget, + targetKey, + targetOfKey, +} from "./form" +import { Message, type MenuKind } from "./message" +import { canManage, type Model } from "./model" + +type Return = PageReturn +export type ChainRoute = Extract< + AppRoute, + { readonly _tag: "AlertsEscalationChainNew" } | { readonly _tag: "AlertsEscalationChain" } +> + +export const DELETE_DIALOG_ID = "escalation-chain-delete" +export const LEAVE_DIALOG_ID = "escalation-chain-leave" + +export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` + +/** The + on the connector above the step at `index` (or below the last one). */ +export const insertButtonId = (index: number): string => `escalation-chain-insert-${index}` + +/** The remove button of one target chip on a step. */ +export const removeTargetButtonId = (key: string, target: string): string => + `escalation-chain-remove-${key}-${target}` + +/** Where Cancel, Save and Delete go back to. */ +export const backHref = (): string => routingTabHref() + +export const init = (route: ChainRoute, context: PageContext): Return => { + const rawId = route._tag === "AlertsEscalationChain" ? Option.some(route.chainId) : Option.none() + const editingId = Option.getOrNull(Option.flatMap(rawId, chainIdFromUrl)) + const model: Model = { + editingId, + isBadId: Option.isSome(rawId) && editingId === null, + existing: Keyed.idle(), + form: emptyForm, + baseline: emptyForm, + destinations: null, + rules: null, + members: null, + isMembersFailed: false, + schedules: null, + isSchedulesFailed: false, + brokenLogos: [], + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), + isDeleting: false, + deleteError: null, + leaveDialog: Dialog.init(LEAVE_DIALOG_ID, "alert-dialog"), + pendingLeaveUrl: null, + grants: [], + isSessionLoaded: false, + } + const common = [ + FetchChainEditorMembers(), + FetchChainEditorSchedules(), + FetchChainEditorSession({ orgId: context.orgId }), + ] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +/** Reads the chain again (a retry, or a refused delete whose rule list may have moved). */ +const refetch = (model: Model, context: PageContext): Return => { + const editingId = model.editingId + if (editingId === null) return { model } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +// LEAVING + +/** The form holds edits a save has not sent. */ +export const isDirty = (model: Pick): boolean => + !isSameChain(model.form, model.baseline) + +/** Leaving with unsaved edits asks first; a save or delete in flight is on its way out already. */ +export const isBlockingNavigation = (model: Model): boolean => + !model.isSaving && !model.isDeleting && canManage(model) && isDirty(model) + +const navigate = (model: Model, url: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ url, isReplace: false }), +}) + +/** A save or delete went through: back to Routing in place of this page, with its toast. */ +const finished = (model: Model, title: string): Return => ({ + // The form is what was saved (or is gone): nothing left to ask about on the way out. + model: { ...model, isSaving: false, isDeleting: false, baseline: model.form }, + outMessage: PageOutMessage.RequestedNavigation({ + url: backHref(), + isReplace: true, + toast: { type: "success", title, description: "" }, + }), +}) + +/** The confirmation over a held navigation (a link, Back, or Cancel). */ +export const askToLeave = (model: Model, url: string): Return => { + const next = Dialog.open(model.leaveDialog) + return { + model: { ...model, leaveDialog: next.model, pendingLeaveUrl: url }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (message) => + Message.GotLeaveDialogMessage({ message }), + ), + } +} + +const leave = (model: Model, url: string): Return => + isBlockingNavigation(model) ? askToLeave(model, url) : navigate(model, url) + +// STEP EDITS + +/** A form edit is dropped while a save is in flight, and for a viewer who may not change chains. */ +const canEdit = (model: Model): boolean => !model.isSaving && canManage(model) + +const withForm = (model: Model, edit: (form: Form) => Form): Return => + canEdit(model) ? { model: { ...model, form: edit(model.form) } } : { model } + +const focus = (id: string) => [FocusChainElement({ id })] + +/** A step that was just added: focus lands on its "Add" button, so its targets come next. */ +const added = (model: Model, form: Form): Return => + form.nextKey === model.form.nextKey + ? { model } + : { + model: { ...model, form }, + commands: focus(triggerId(menuIdOf("targets", `s${model.form.nextKey}`))), + } + +/** A removed step hands focus to the step before it, or to the + that now leads the chain. */ +const removed = (model: Model, key: string): Return => { + const form = removeStep(model.form, key) + if (form.steps.length === model.form.steps.length) return { model } + const index = Option.getOrElse( + Arr.findFirstIndex(model.form.steps, (step) => step.key === key), + () => 0, + ) + const previous = index === 0 ? undefined : model.form.steps[index - 1] + return { + model: { ...model, form }, + commands: focus( + previous === undefined ? insertButtonId(0) : triggerId(menuIdOf("actions", previous.key)), + ), + } +} + +/** A removed target hands focus to the chip that takes its place, or to the step's Add button after the last. */ +const removedTarget = (model: Model, key: string, target: string): Return => { + if (!canEdit(model)) return { model } + const targets = Option.match( + Arr.findFirst(model.form.steps, (step) => step.key === key), + { + onNone: (): ReadonlyArray => [], + onSome: (step) => Arr.map(step.targets, targetKey), + }, + ) + return Option.match( + Arr.findFirstIndex(targets, (existing) => existing === target), + { + onNone: () => ({ model }), + onSome: (index) => { + const next = targets[index + 1] + return { + model: { ...model, form: removeTarget(model.form, key, target) }, + commands: focus( + next === undefined + ? triggerId(menuIdOf("targets", key)) + : removeTargetButtonId(key, next), + ), + } + }, + }, + ) +} + +const stepAction = (model: Model, key: string, value: string): Return => { + switch (value) { + case "up": + case "down": + return { + model: { ...model, form: moveStep(model.form, key, value) }, + commands: focus(triggerId(menuIdOf("actions", key))), + } + case "duplicate": + return added(model, duplicateStep(model.form, key)) + case "remove": + return removed(model, key) + default: + return { model } + } +} + +/** A step's menus: a target pick adds it to the step, an action edits the steps. */ +const stepMenu = (model: Model, kind: MenuKind, key: string, message: Menu.Message): Return => { + const id = menuIdOf(kind, key) + const result = Menu.update(model.menus[id] ?? Menu.init(id), message) + const next: Model = { ...model, menus: { ...model.menus, [id]: result.model } } + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotMenuMessage({ kind, key, message: child }), + ) + const picked = + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none() + // The menus still open and close while saving, but a pick no longer edits the chain. + return Option.match(canEdit(model) ? picked : Option.none(), { + onNone: () => ({ model: next, commands }), + onSome: (value) => { + if (kind === "actions") { + const acted = stepAction(next, key, value) + return { model: acted.model, commands: [...commands, ...(acted.commands ?? [])] } + } + return Option.match(targetOfKey(value), { + onNone: () => ({ model: next, commands }), + onSome: (target) => ({ + model: { ...next, form: addTarget(next.form, key, target) }, + commands, + }), + }) + }, + }) +} + +const save = (model: Model): Return => + !canEdit(model) + ? { model } + : Option.match(payloadOf(model.form), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveFailure: null }, + commands: [SaveChain({ editingId: model.editingId, payload })], + }), + }) + +const liftDelete = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteDialog: step.model, + deleteError: step.model.isOpen ? model.deleteError : null, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteDialogMessage({ message }), + ), +}) + +const deleted = (model: Model): Return => finished(model, "Escalation chain deleted") + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + // The form fills once, from the first read; a later read only refreshes who uses the chain. + const isFirst = Option.isNone(AsyncData.getData(model.existing.data)) + return { + model: Result.match(result, { + onSuccess: (chain) => + isFirst + ? { + ...model, + existing, + form: formFromChain(chain), + baseline: formFromChain(chain), + } + : { ...model, existing }, + onFailure: () => ({ ...model, existing }), + }), + } + }, + ClickedRetryExisting: () => refetch(model, context), + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + UpdatedRules: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, + // Without members a person reads "Member", and the picker says people could not be loaded. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), + }), + CompletedFetchSchedules: ({ result }) => + Result.match(result, { + onSuccess: (schedules) => ({ model: { ...model, schedules, isSchedulesFailed: false } }), + onFailure: () => ({ model: { ...model, isSchedulesFailed: true } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedName: ({ value }) => withForm(model, (form) => ({ ...form, name: value })), + ChangedDescription: ({ value }) => withForm(model, (form) => ({ ...form, description: value })), + ChangedRepeatCount: ({ value }) => withForm(model, (form) => ({ ...form, repeatCount: value })), + ChangedWait: ({ key, value }) => + withForm(model, (form) => editStep(form, key, (step) => ({ ...step, waitMinutes: value }))), + ClickedInsertStep: ({ index }) => + canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, + GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), + ClickedRemoveTarget: ({ key, target }) => removedTarget(model, key, target), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + // Back to Routing, as the rule editor goes back to Rules: the list shows the saved chain. + onSuccess: (): Return => + finished( + model, + model.editingId === null ? "Escalation chain created" : "Escalation chain saved", + ), + onFailure: (failure): Return => ({ + model: { ...model, isSaving: false, saveFailure: failure }, + }), + }), + GotDeleteDialogMessage: ({ message: child }) => + model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), + ClickedDelete: () => + model.editingId === null || !canManage(model) + ? { model } + : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), + ClickedConfirmDelete: () => + model.editingId === null || model.isDeleting || !canManage(model) + ? { model } + : { + model: { ...model, isDeleting: true, deleteError: null }, + commands: [DeleteChain({ chainId: model.editingId })], + }, + CompletedDelete: ({ result }) => + Result.match(result, { + onSuccess: (): Return => deleted(model), + onFailure: (failure): Return => { + // Someone else deleted it first: the outcome the viewer asked for. + if (failure.code === CHAIN_NOT_FOUND_CODE) return deleted(model) + const settled = { ...model, isDeleting: false } + // A rule took the chain since this page read it: read it again, so the dialog + // lists the rule and holds its Delete. + return failure.code === CHAIN_IN_USE_CODE + ? refetch( + { + ...settled, + deleteError: + "An alert rule still starts this chain. Remove it from the rule's severity tiers first.", + }, + context, + ) + : { model: { ...settled, deleteError: failure.message } } + }, + }), + ClickedCancel: () => leave(model, backHref()), + GotLeaveDialogMessage: ({ message: child }) => { + const next = Dialog.update(model.leaveDialog, child) + return { + model: { ...model, leaveDialog: next.model }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + ClickedDiscard: () => { + const closed = Dialog.close(model.leaveDialog) + return { + ...navigate( + { ...model, leaveDialog: closed.model, baseline: model.form }, + model.pendingLeaveUrl ?? backHref(), + ), + commands: FoldkitCommand.mapMessages(closed.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + FailedLoadProviderLogo: ({ type }) => + model.brokenLogos.includes(type) + ? { model } + : { model: { ...model, brokenLogos: [...model.brokenLogos, type] } }, + CompletedFocus: () => ({ model }), + }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts new file mode 100644 index 0000000000..f6f7365310 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -0,0 +1,759 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import type { AlertDestinationId } from "@maple/domain/http" +import type { V2EscalationTarget } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { rowToAlertDestinationDocument } from "@maple/web-data/collections/alerts" +import { Option, Array as Arr, String as Str } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +import type { FieldState } from "@maple/views/ui/input" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { chainConnector } from "../../kit/chain-connector" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" +import { dashboardPage } from "../../kit/dashboard-page" +import { managersOnlyPage } from "../../kit/form-section" +import { type PickedChip, pickChips } from "../../kit/pick-chips" +import { resourceNotFound } from "../../kit/resource-not-found" +import { CHAIN_NOT_FOUND_CODE, chainSummary, routingTabHref } from "../shared/escalation-chains" +import { ruleHref } from "../shared/links" +import { memberName, memberOf } from "../shared/org-members" +import { providerLogo } from "../shared/providers" +import { + canAddStep, + descriptionError, + formIssues, + nameError, + numberOf, + previewOf, + repeatError, + type StepDraft, + stepsError, + targetKey, + targetsError, + waitError, +} from "./form" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { + backHref, + DELETE_DIALOG_ID, + insertButtonId, + LEAVE_DIALOG_ID, + menuIdOf, + removeTargetButtonId, +} from "./update" + +// The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. + +type H = HtmlBuilder + +/** The page's name: the chain as last read, or "New escalation chain". A URL id that is no chain's is not a new one. */ +export const chainTitle = (model: Pick): string => + model.editingId === null && !model.isBadId + ? "New escalation chain" + : Option.getOrElse( + Option.map(AsyncData.getData(model.existing.data), (chain) => chain.name), + () => "Escalation chain", + ) + +const nameOr = ( + rows: ReadonlyArray | null, + id: string, + fallback: string, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), + ) + +/** A person by id; someone removed from the org reads "Unavailable member", as a deleted destination does. */ +const personName = (model: Model, userId: string): string => + memberName(model.members, userId, "Unavailable member") + +/** A field's error once a save was tried, or at once for a number typed out of range. */ +const shown = (model: Model, error: Option.Option, isImmediate = false): Option.Option => + model.isShowingErrors || isImmediate ? error : Option.none() + +const errorText = (h: H, error: Option.Option): ReadonlyArray => + Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }) + +const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) + +const idsOf = (id: string): FieldIds => ({ control: id, label: `${id}-label`, error: `${id}-error` }) + +const stateOf = (value: string, isInvalid: boolean): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +/** Nothing can be typed: a save is in flight, or the viewer may only read the chain. */ +const isLocked = (model: Model): boolean => model.isSaving || !canManage(model) + +// STEP CARDS + +/** A destination's own mark (Slack, PagerDuty, email); a bell once the destination is gone. */ +const destinationMedia = (h: H, model: Model, id: AlertDestinationId): Html => + Option.match( + Option.flatMap( + Arr.findFirst(model.destinations ?? [], (row) => row.id === id), + rowToAlertDestinationDocument, + ), + { + onNone: () => icon(h, "BellIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + onSome: (destination) => + providerLogo(h, { + type: destination.type, + chatConnector: destination.chatConnector ?? undefined, + size: 30, + isBare: true, + className: hstackClass({ className: "shrink-0" }), + isLogoBroken: model.brokenLogos.includes(destination.type), + onLogoError: Message.FailedLoadProviderLogo({ type: destination.type }), + }), + }, + ) + +/** Who a schedule pages right now, once the schedules have loaded. */ +const onCallNote = (model: Model, scheduleId: string): string | undefined => + Option.getOrUndefined( + Option.map( + Arr.findFirst(model.schedules ?? [], (schedule) => schedule.id === scheduleId), + (schedule) => + schedule.on_call_now === null + ? "nobody on call" + : `${personName(model, schedule.on_call_now.user_id)} on call`, + ), + ) + +/** A picked target's chip: a destination's logo, a member's avatar, a schedule's calendar. */ +const targetChip = ( + h: H, + model: Model, + key: string, + index: number, + target: V2EscalationTarget, +): PickedChip => { + const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + const removeId = removeTargetButtonId(key, targetKey(target)) + // The step is named: the same target may sit on several steps. + const onStep = `on step ${index + 1}` + switch (target.type) { + case "destination": { + const name = nameOr(model.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: destinationMedia(h, model, target.destination_id), + removeLabel: `Stop notifying ${name} ${onStep}`, + removeId, + onRemove, + } + } + case "user": { + const name = personName(model, target.user_id) + return { + key: targetKey(target), + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), + ), + }), + removeLabel: `Stop paging ${name} ${onStep}`, + removeId, + onRemove, + } + } + case "schedule": { + const name = nameOr(model.schedules, target.schedule_id, "Schedule") + const detail = onCallNote(model, target.schedule_id) + return { + key: targetKey(target), + label: name, + media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + ...(detail === undefined ? undefined : { detail }), + removeLabel: `Stop paging ${name} ${onStep}`, + removeId, + onRemove, + } + } + } +} + +/** The picker with nothing to offer: where a destination is made (in its own tab, keeping the draft). */ +const nothingToAdd = (h: H, model: Model): Html => + h.div( + [h.Class("px-2 py-1.5")], + [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + (model.destinations ?? []).length === 0 + ? "No destinations yet. " + : "Everything is on this step already. ", + textLink(h, { href: routingTabHref(), attributes: [h.Target("_blank"), h.Rel("noopener")] }, [ + "Create a destination", + ]), + ]), + ], + ) + +const loadingEntry = (label: string): MenuEntry => ({ _tag: "Label", label }) + +/** The target picker: destinations, people and schedules not yet on the step, by kind. */ +const addEntries = (h: H, model: Model, step: StepDraft): ReadonlyArray => { + const picked = new Set(Arr.map(step.targets, targetKey)) + const group = ( + groupLabel: string, + items: ReadonlyArray>, + ): ReadonlyArray => { + const open = Arr.filter(items, (item) => !picked.has(item.value)) + return open.length === 0 + ? [] + : [ + { + _tag: "Group", + label: groupLabel, + entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), + }, + ] + } + const entries: ReadonlyArray = [ + ...(model.destinations === null + ? [loadingEntry("Loading destinations")] + : group( + "Destinations", + Arr.map(model.destinations, (row) => ({ + value: `destination:${row.id}`, + label: row.name, + })), + )), + ...(model.members === null + ? [loadingEntry(model.isMembersFailed ? "People could not be loaded" : "Loading people")] + : group( + "People", + Arr.map(model.members, (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + )), + ...(model.schedules === null + ? [loadingEntry(model.isSchedulesFailed ? "Schedules could not be loaded" : "Loading schedules")] + : group( + "Whoever is on call on", + Arr.map(model.schedules, (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + )), + ] + return entries.length === 0 ? [{ _tag: "Content", content: nothingToAdd(h, model) }] : entries +} + +const stepActions = (model: Model, index: number): ReadonlyArray => { + const count = model.form.steps.length + return [ + { _tag: "Item", value: "up", label: "Move up", icon: "ArrowUpIcon", isDisabled: index === 0 }, + { + _tag: "Item", + value: "down", + label: "Move down", + icon: "ArrowDownIcon", + isDisabled: index === count - 1, + }, + { + _tag: "Item", + value: "duplicate", + label: "Duplicate", + icon: "CopyIcon", + isDisabled: !canAddStep(model.form), + }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "remove", + label: "Remove", + icon: "TrashIcon", + variant: "destructive", + isDisabled: count === 1, + }, + ] +} + +/** What follows a step's wait: the next step, another pass, or nothing once the chain stops. */ +const waitTail = (model: Model, index: number): string => { + if (index < model.form.steps.length - 1) return "minutes for an acknowledgement" + return (numberOf(model.form.repeatCount) ?? 0) > 0 + ? "minutes for an acknowledgement, then repeat" + : "minutes, then stop" +} + +const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { + const key = step.key + const wait = waitError(step.waitMinutes) + const targetsMenuId = menuIdOf("targets", key) + const actionsMenuId = menuIdOf("actions", key) + const waitId = `escalation-chain-wait-${key}` + const shownWait = shown(model, wait, isOutOfRange(step.waitMinutes, wait)) + const waitState = stateOf(step.waitMinutes, Option.isSome(shownWait)) + return panel(h, { padded: "sm", className: "gap-2", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", align: "start" }, [ + hstack(h, { gap: "1.5", wrap: true, grow: true, shrink: true, className: "min-h-7" }, [ + typography(h, { size: "sm", weight: "medium", className: "mr-0.5" }, [ + index === 0 ? "Notify" : "Then notify", + ]), + ...pickChips(h, { + entries: Arr.map(step.targets, (target) => targetChip(h, model, key, index, target)), + menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), + toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), + addEntries: addEntries(h, model, step), + addLabel: "Add destination, person or schedule", + addAriaLabel: `Add destination, person or schedule to step ${index + 1}`, + isDisabled: model.isSaving, + isReadOnly: !canManage(model), + emptyText: "nobody", + }), + ]), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: actionsMenuId, + model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), + entries: stepActions(model, index), + label: `Actions for step ${index + 1}`, + toParentMessage: (message) => + Message.GotMenuMessage({ kind: "actions", key, message }), + }), + ] + : []), + ]), + ...errorText(h, shown(model, targetsError(step.targets))), + hstack(h, { gap: "2", wrap: true }, [ + fieldLabel(h, { ids: idsOf(waitId), state: waitState, className: "font-normal" }, "then wait"), + input(h, { + ids: idsOf(waitId), + state: waitState, + className: "w-20", + isDisabled: isLocked(model), + attributes: [ + h.Type("number"), + h.Min("1"), + h.Attribute("max", String(MAX_ESCALATION_WAIT_MINUTES)), + h.Attribute("step", "1"), + h.AriaLabel(`Step ${index + 1} wait, in minutes`), + h.Value(step.waitMinutes), + h.OnInput((value) => Message.ChangedWait({ key, value })), + ], + }), + typography(h, { size: "sm" }, [waitTail(model, index)]), + ]), + ...errorText(h, shownWait), + ]) +} + +/** The line between two cards; while a step can be added, an editor gets a + on it that adds one there. */ +const connector = (h: H, model: Model, index: number): Html => { + const count = model.form.steps.length + return chainConnector(h, { + attributes: [h.Key(`connector-${index}`)], + insert: + canManage(model) && !model.isSaving && canAddStep(model.form) + ? { + id: insertButtonId(index), + label: + index === 0 + ? "Add a step at the start" + : index === count + ? "Add a step at the end" + : `Add a step after step ${index}`, + onClick: Message.ClickedInsertStep({ index }), + isPinned: index === count, + } + : null, + }) +} + +const startCard = (h: H): Html => + panel(h, { padded: "sm", tone: "muted" }, [ + hstack(h, { gap: "2" }, [ + icon(h, "BellIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), + typography(h, { size: "sm" }, ["When an alert escalates to this chain"]), + ]), + ]) + +const endCard = (h: H, model: Model): Html => { + const repeat = repeatError(model.form.repeatCount) + const count = numberOf(model.form.repeatCount) ?? 0 + const id = "escalation-chain-repeat" + const shownRepeat = shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat)) + const state = stateOf(model.form.repeatCount, Option.isSome(shownRepeat)) + return panel(h, { padded: "sm", tone: "muted", className: "gap-2" }, [ + hstack(h, { gap: "2" }, [ + icon(h, "ArrowPathIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), + hstack(h, { gap: "2", wrap: true, shrink: true }, [ + fieldLabel( + h, + { ids: idsOf(id), state, className: "font-normal" }, + "If nobody acknowledges, repeat the chain", + ), + input(h, { + ids: idsOf(id), + state, + className: "w-16", + isDisabled: isLocked(model), + attributes: [ + h.Type("number"), + h.Min("0"), + h.Attribute("max", String(MAX_ESCALATION_REPEATS)), + h.Attribute("step", "1"), + h.Value(model.form.repeatCount), + h.OnInput((value) => Message.ChangedRepeatCount({ value })), + ], + }), + typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + ]), + ]), + ...errorText(h, shownRepeat), + ]) +} + +const chainColumn = (h: H, model: Model): Html => + vstack(h, { className: "min-w-0" }, [ + startCard(h), + ...Arr.flatMap(model.form.steps, (step, index) => [ + connector(h, model, index), + stepCard(h, model, step, index), + ]), + connector(h, model, model.form.steps.length), + endCard(h, model), + ...errorText(h, shown(model, stepsError(model.form.steps))), + ...(canManage(model) && !canAddStep(model.form) + ? [ + typography(h, { size: "xs", tone: "muted", as: "p", className: "mt-2" }, [ + `A chain has at most ${MAX_ESCALATION_STEPS} steps`, + ]), + ] + : []), + ]) + +// ASIDE + +const destinationName = + (model: Model) => + (id: AlertDestinationId): string => + nameOr(model.destinations, id, "Destination") + +/** The chain in one line, as it reads on the Routing section and in the rule editor. */ +const preview = (h: H, model: Model): Html => { + const chain = previewOf(model.form) + const summary = + chain.steps.length === 0 + ? "Pick who the first step pages to see the chain in one line." + : chainSummary(chain, destinationName(model), { + userName: (id) => personName(model, id), + scheduleName: (id) => + model.schedules === null ? undefined : nameOr(model.schedules, id, "Schedule"), + }) + return vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, ["Preview"]), + typography(h, { size: "sm", as: "p", className: "break-words" }, [summary]), + ]) +} + +/** Name, notes and the one-line preview. */ +const details = (h: H, model: Model): Html => { + const name = shown(model, nameError(model.form.name)) + const description = shown(model, descriptionError(model.form.description)) + const nameState = stateOf(model.form.name, Option.isSome(name)) + const notesIds = idsOf("escalation-chain-description") + return panel(h, { padded: "sm", className: "order-first gap-4 lg:order-none" }, [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), + input(h, { + ids: idsOf("escalation-chain-name"), + state: nameState, + isDisabled: isLocked(model), + attributes: [ + h.Value(model.form.name), + h.Placeholder("Checkout on-call"), + h.Attribute("autocomplete", "off"), + h.OnInput((value) => Message.ChangedName({ value })), + ], + }), + ...errorText(h, name), + ]), + vstack(h, { gap: "1.5" }, [ + fieldLabel( + h, + { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, + "Notes", + ), + textarea(h, { + id: notesIds.control, + defaultValue: model.form.description, + isDisabled: isLocked(model), + attributes: [ + // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. + h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), + h.AriaLabelledBy(notesIds.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(2), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorText(h, description), + ]), + preview(h, model), + ]) +} + +/** The rules whose tiers start this chain, as links; ones not synced yet are counted. */ +const usedBy = (h: H, model: Model): ReadonlyArray => + Option.match(AsyncData.getData(model.existing.data), { + onNone: () => [], + onSome: (chain) => { + const ruleIds = chain.rule_ids + const rules = Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)) + const others = ruleIds.length - rules.length + return [ + vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, [ + ruleIds.length === 0 + ? "Not used by any rule" + : `Used by ${ruleIds.length} ${pluralize(ruleIds.length, "rule")}`, + ]), + ...(ruleIds.length === 0 + ? [] + : [ + h.ul( + [h.Class("space-y-1")], + [ + ...Arr.map(rules, (rule) => + h.li( + [h.Key(rule.id)], + [ + typography(h, { size: "sm" }, [ + textLink(h, { href: ruleHref(rule.id) }, [rule.name]), + ]), + // A paused rule starts no chain until it is resumed. + ...(rule.enabled + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + " · paused", + ]), + ]), + ], + ), + ), + ...(others > 0 + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${others} more`, + ]), + ], + ), + ] + : []), + ], + ), + ]), + ]), + ] + }, + }) + +/** Under the details: who uses the chain, and its quiet Delete. */ +const meta = (h: H, model: Model): ReadonlyArray => { + const rows = [ + ...usedBy(h, model), + ...(model.editingId !== null && canManage(model) + ? [ + button( + h, + { + variant: "destructive-ghost", + size: "sm", + className: "-ml-2 self-start", + isDisabled: model.isSaving || model.isDeleting, + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete chain"], + ), + ] + : []), + ] + return rows.length === 0 ? [] : [vstack(h, { gap: "3", className: "px-1" }, rows)] +} + +// Below `lg` the aside dissolves into the grid: the details lead, the chain follows, then the rest. +const aside = (h: H, model: Model): Html => + h.aside( + [h.Class("contents lg:sticky lg:top-0 lg:block lg:space-y-4")], + [details(h, model), ...meta(h, model)], + ) + +/** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ +const deleteDialog = (h: H, model: Model): Html => { + const confirm = chainDeleteConfirm(h, AsyncData.getData(model.existing.data), model.rules) + return confirmDialog(h, { + slotId: DELETE_DIALOG_ID, + model: model.deleteDialog, + toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), + title: `Delete ${chainTitle(model)}?`, + description: confirm.description, + children: [ + ...confirm.ruleList, + ...(model.deleteError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteError]), + ]), + ]), + ], + confirmLabel: "Delete chain", + isPending: model.isDeleting, + isConfirmDisabled: confirm.isInUse, + onConfirm: Message.ClickedConfirmDelete(), + }) +} + +/** "Unsaved changes": a link, Back or Cancel held while the form has edits. */ +const leaveDialog = (h: H, model: Model): Html => + confirmDialog(h, { + slotId: LEAVE_DIALOG_ID, + model: model.leaveDialog, + toParentMessage: (message) => Message.GotLeaveDialogMessage({ message }), + title: "Unsaved changes", + description: "This chain has changes you have not saved. Leave without saving them?", + cancelLabel: "Stay", + confirmLabel: "Discard changes", + onConfirm: Message.ClickedDiscard(), + }) + +// PAGE + +const actions = (h: H, model: Model): Html => + canManage(model) + ? hstack(h, { gap: "2" }, [ + button(h, { variant: "outline", attributes: [h.OnClick(Message.ClickedCancel())] }, [ + "Cancel", + ]), + button(h, { isLoading: model.isSaving, attributes: [h.OnClick(Message.ClickedSave())] }, [ + model.editingId === null ? "Create chain" : "Save chain", + ]), + ]) + : linkButton(h, { variant: "outline", href: backHref() }, ["Back to routing"]) + +const notices = (h: H, model: Model): ReadonlyArray => [ + ...(canManage(model) + ? [] + : [ + alert(h, { variant: "info", size: "sm" }, [ + icon(h, "ShieldIcon"), + alertTitle(h, ["Only org admins and engineers can change escalation chains"]), + ]), + ]), + ...(model.saveFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", + ]), + alertDescription(h, [ + model.saveFailure.code === CHAIN_NOT_FOUND_CODE + ? "This chain no longer exists. Someone deleted it while you were editing." + : failureMessage(model.saveFailure), + ]), + ]), + ]), + ...(model.isShowingErrors && formIssues(model.form).length > 0 + ? [ + typography(h, { size: "sm", tone: "destructive", as: "p", attributes: [h.Role("status")] }, [ + "Fix the highlighted fields to save the chain.", + ]), + ] + : []), +] + +const notFound = (h: H): Html => + resourceNotFound(h, { + title: "Escalation chain not found", + description: "It may have been deleted, or the link is wrong.", + back: { href: backHref(), label: "Back to routing" }, + }) + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: chainTitle(model), + description: + "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", + } + const loading = dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (model.isBadId) return dashboardPage(h, header, [notFound(h)]) + if (!model.isSessionLoaded) return loading + // Nothing to read on a new chain: a viewer who cannot create one is told who can. + if (!isEdit && !canManage(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create escalation chains", + back: { href: backHref(), label: "Back to routing" }, + }) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + model.existing.data.error.code === CHAIN_NOT_FOUND_CODE + ? notFound(h) + : errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the chain", + onRetry: Message.ClickedRetryExisting(), + }), + ]) + if (isEdit && Option.isNone(AsyncData.getData(model.existing.data))) return loading + return dashboardPage(h, { ...header, actions: actions(h, model) }, [ + vstack(h, { gap: "4" }, [ + ...notices(h, model), + h.div( + [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], + [chainColumn(h, model), aside(h, model)], + ), + ]), + deleteDialog(h, model), + leaveDialog(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts deleted file mode 100644 index 47c7a2b37e..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" -import { describe, test } from "vitest" -import * as Dialog from "@maple/views/ui/dialog" -import { MountDialog } from "@maple/views/ui/dialog" -import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" -import { init, Message, openEdit, update } from "./dialog" -import { view as dialogView } from "./view" - -const view = withViewInputs(dialogView, { - id: "chain", - destinations: chainDestinations, - members: [], - schedules: [], -})() -const editing = () => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog view", () => { - test("while a save is in flight the fields and step controls are disabled", () => { - scene( - { update, view }, - given(update(editing(), Message.ClickedSave()).model), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).toBeDisabled(), - expectView(role("textbox", { name: "Description" })).toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toBeDisabled(), - expectView(role("button", { name: "Move step 1 down" })).toBeDisabled(), - expectView(role("button", { name: "Remove step 1" })).toBeDisabled(), - expectView(role("button", { name: "Add step" })).toBeDisabled(), - expectView(role("button", { name: /^Stop notifying/ })).toBeDisabled(), - ) - }) - - test("before a save the same controls are editable", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), - expectView(role("button", { name: "Add step" })).not.toBeDisabled(), - expectView(role("button", { name: /^Stop notifying .+ in step 1$/u })).toExist(), - ) - }) - - test("the wait and repeat inputs take their range and copy from the API's limits", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAttr( - "max", - String(MAX_ESCALATION_REPEATS), - ), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( - `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - ), - expectView(role("spinbutton", { name: /^Step 1: then wait/ })).toHaveAttr( - "max", - String(MAX_ESCALATION_WAIT_MINUTES), - ), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts deleted file mode 100644 index 61ad9a0595..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Option, Array as Arr, Schema } from "effect" -import { AlertDestinationId } from "@maple/domain/http" -import * as Menu from "@maple/views/ui/menu" -import { describe, expect, it } from "vitest" -import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" -import { memberId, mixedTargetChain, scheduleUuid } from "../shared/oncall-test-fixtures" -import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" - -const asDestination = Schema.decodeSync(AlertDestinationId) -const run = (model: Model, messages: ReadonlyArray) => - Arr.reduce(messages, model, (current, message) => update(current, message).model) -const opened = (): Model => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog", () => { - it("an edited chain round-trips to the payload it was read from", () => { - expect(payloadOf(opened())).toEqual( - Option.some({ - name: "Checkout on-call", - description: null, - steps: [ - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(1) }], - wait_minutes: 10, - }, - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(2) }], - wait_minutes: 15, - }, - ], - repeat_count: 1, - }), - ) - }) - - it("a chain built through the API keeps its people and schedules through an edit", () => { - const chain = mixedTargetChain(chainDestinationUuid(1)) - const model = openEdit(init("chain"), chain).model - expect(Option.map(payloadOf(model), (payload) => payload.steps)).toEqual(Option.some(chain.steps)) - const key = model.steps[1]?.key ?? "" - const removed = run(model, [ - Message.ClickedRemoveTarget({ key, target: `schedule:${scheduleUuid(1)}` }), - ]) - expect(Option.map(payloadOf(removed), (payload) => payload.steps[1]?.targets)).toEqual( - Option.some([{ type: "user", user_id: memberId(1) }]), - ) - }) - - it("adds a person or a schedule from the step's menu, once each", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const pick = (value: string) => - Message.GotStepMenuMessage({ - key, - message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value } }), - }) - const added = run(model, [ - pick(`user:${memberId(3)}`), - pick(`schedule:${scheduleUuid(2)}`), - pick(`user:${memberId(3)}`), - ]) - expect(added.steps[0]?.targets).toEqual([ - { type: "destination", destination_id: chainDestinationUuid(1) }, - { type: "user", user_id: memberId(3) }, - { type: "schedule", schedule_id: scheduleUuid(2) }, - ]) - }) - - it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const broken = (messages: ReadonlyArray) => Option.isNone(payloadOf(run(model, messages))) - expect(broken([Message.ChangedName({ value: " " })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "0" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "1441" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) - expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) - expect( - broken([ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]), - ).toBe(true) - expect( - broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), - ).toBe(false) - }) - - it("a save with errors sends nothing and shows them; a valid one sends once", () => { - const model = run(opened(), [Message.ChangedName({ value: "" })]) - const refused = update(model, Message.ClickedSave()) - expect(refused.commands ?? []).toEqual([]) - expect(refused.model.isShowingErrors).toBe(true) - const saving = update(opened(), Message.ClickedSave()) - expect(Arr.map(saving.commands ?? [], ({ name }) => name)).toEqual(["SaveEscalationChain"]) - expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) - }) - - it("edits sent while a save is in flight leave the form as it was sent", () => { - const saving = update(opened(), Message.ClickedSave()).model - const key = saving.steps[0]?.key ?? "" - const edited = run(saving, [ - Message.ChangedName({ value: "Renamed mid-save" }), - Message.ChangedDescription({ value: "typed while saving" }), - Message.ChangedWait({ key, value: "30" }), - Message.ChangedRepeatCount({ value: "3" }), - Message.ClickedAddStep(), - Message.ClickedRemoveStep({ key }), - Message.ClickedMoveStep({ key, direction: "down" }), - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(edited).toEqual(saving) - }) - - it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { - const model = opened() - const [first, second] = model.steps - const moved = run(model, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]) - expect(Arr.map(moved.steps, (step) => step.key)).toEqual([second?.key, first?.key]) - expect( - run(moved, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]).steps, - ).toEqual(moved.steps) - const full = run( - model, - Arr.makeBy(12, () => Message.ClickedAddStep()), - ) - expect(full.steps).toHaveLength(10) - }) - - it("an edit sends a step's member and schedule targets back, and a schedule alone is enough", () => { - const chain = chainOf({ - n: 2, - name: "Checkout on-call", - steps: [ - { destinations: [1], users: ["user_ben"], wait: 10 }, - { destinations: [], schedules: [1], wait: 15 }, - ], - }) - const renamed = run(openEdit(init("chain"), chain).model, [ - Message.ChangedName({ value: "Checkout" }), - ]) - const saving = update(renamed, Message.ClickedSave()) - expect(saving.commands?.[0]?.args).toMatchObject({ - payload: { - name: "Checkout", - steps: Arr.map(chain.steps, (step) => ({ targets: step.targets })), - }, - }) - const key = renamed.steps[0]?.key ?? "" - const withoutDestination = run(renamed, [ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( - Option.some([{ type: "user", user_id: "user_ben" }]), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts deleted file mode 100644 index c89f0ce055..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { - AlertDestinationId, - EscalationChainDescription, - EscalationChainId, - EscalationChainName, - EscalationRepeatCount, - EscalationWaitMinutes, - MAX_ESCALATION_REPEATS, - MAX_ESCALATION_STEPS, - MAX_ESCALATION_STEP_TARGETS, - MAX_ESCALATION_WAIT_MINUTES, - OnCallScheduleId, - UserId, -} from "@maple/domain/http" -import { - V2EscalationChain, - V2EscalationChainCreateParams, - type V2EscalationStep, - V2EscalationTarget, -} from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" -import { Command, type Update } from "foldkit" -import { defineMessageUnion } from "foldkit/message" -import { LoadFailure } from "@maple/views/data/load-failure" -import * as Dialog from "@maple/views/ui/dialog" -import * as Menu from "@maple/views/ui/menu" -import type { Resources } from "../../../platform/resources" -import { saveEscalationChain } from "../shared/escalation-chains" - -// "New escalation chain" / "Edit escalation chain": the Routing tab's dialog, as one Submodel. - -/** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ -export const StepDraft = Schema.Struct({ - key: Schema.String, - /** Destinations, people and schedules, in the order they were added; all kept on load. */ - targets: Schema.Array(V2EscalationTarget), - waitMinutes: Schema.String, -}) -export type StepDraft = typeof StepDraft.Type - -export const Model = Schema.Struct({ - dialog: Dialog.Model, - editingId: Schema.NullOr(EscalationChainId), - name: Schema.String, - description: Schema.String, - steps: Schema.Array(StepDraft), - repeatCount: Schema.String, - nextKey: Schema.Number, - /** Each step's "Add target" menu, by step key. */ - menus: Schema.Record(Schema.String, Menu.Model), - isSaving: Schema.Boolean, - /** A save was tried: every field shows what is wrong with it. */ - isShowingErrors: Schema.Boolean, - saveError: Schema.NullOr(Schema.String), - /** Bumped on every open: a save's result only settles the form it was sent from. */ - openCount: Schema.Number, -}) -export type Model = typeof Model.Type - -export const DEFAULT_WAIT_MINUTES = "10" - -const emptyStep = (key: number): StepDraft => ({ - key: `s${key}`, - targets: [], - waitMinutes: DEFAULT_WAIT_MINUTES, -}) - -export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: 0, -}) - -export const Direction = Schema.Literals(["up", "down"]) - -export const Message = defineMessageUnion({ - GotDialogMessage: { message: Dialog.Message }, - ChangedName: { value: Schema.String }, - ChangedDescription: { value: Schema.String }, - ChangedRepeatCount: { value: Schema.String }, - ChangedWait: { key: Schema.String, value: Schema.String }, - ClickedAddStep: {}, - ClickedRemoveStep: { key: Schema.String }, - ClickedMoveStep: { key: Schema.String, direction: Direction }, - GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, - ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, - ClickedSave: {}, - CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, -}) -export type Message = typeof Message.Type - -export const OutMessage = defineMessageUnion({ - SavedChain: { chain: V2EscalationChain, isNew: Schema.Boolean }, -}) -export type OutMessage = typeof OutMessage.Type - -type Return = Update.ReturnWithOutMessage - -export const menuIdOf = (model: Pick, key: string): string => - `${model.dialog.id}-step-${key}` - -// VALIDATION: the same limits the API's schemas hold. - -const isName = Schema.is(EscalationChainName) -const isDescription = Schema.is(EscalationChainDescription) -const isWait = Schema.is(EscalationWaitMinutes) -const isRepeat = Schema.is(EscalationRepeatCount) - -export const numberOf = (raw: string): number | null => - Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) - -export const nameError = (name: string): Option.Option => - Str.isEmpty(Str.trim(name)) - ? Option.some("Name the chain") - : isName(Str.trim(name)) - ? Option.none() - : Option.some("Keep the name to 120 characters") - -export const descriptionError = (description: string): Option.Option => - isDescription(Str.trim(description)) - ? Option.none() - : Option.some("Keep the description to 500 characters") - -export const waitError = (raw: string): Option.Option => - isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) - -export const repeatError = (raw: string): Option.Option => - isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) - -export const targetsError = (targets: ReadonlyArray): Option.Option => - targets.length === 0 - ? Option.some("Pick at least one destination, person or schedule") - : targets.length > MAX_ESCALATION_STEP_TARGETS - ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) - : Option.none() - -/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ -export const targetKey = (target: V2EscalationTarget): string => { - switch (target.type) { - case "destination": - return `destination:${target.destination_id}` - case "user": - return `user:${target.user_id}` - case "schedule": - return `schedule:${target.schedule_id}` - } -} - -const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) -const decodeUserId = Schema.decodeUnknownOption(UserId) -const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) - -/** The target a menu value names, if it is well formed. */ -export const targetOfKey = (key: string): Option.Option => { - const [kind, ...rest] = Str.split(":")(key) - const id = Arr.join(rest, ":") - switch (kind) { - case "destination": - return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ - type: "destination", - destination_id, - })) - case "user": - return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) - case "schedule": - return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ - type: "schedule", - schedule_id, - })) - default: - return Option.none() - } -} - -export const stepsError = (steps: ReadonlyArray): Option.Option => - steps.length === 0 - ? Option.some("Add at least one step") - : steps.length > MAX_ESCALATION_STEPS - ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) - : Option.none() - -const stepOf = (step: StepDraft): Option.Option => { - const targets = step.targets - const minutes = numberOf(step.waitMinutes) - return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(targetsError(targets)) && - minutes !== null && - isWait(minutes) - ? Option.some({ targets, wait_minutes: minutes }) - : Option.none() -} - -/** The create payload the form describes, or none while any field is wrong. */ -export const payloadOf = (model: Model): Option.Option => { - const steps = Option.all(Arr.map(model.steps, stepOf)) - const repeat = numberOf(model.repeatCount) - if ( - Option.isSome(nameError(model.name)) || - Option.isSome(descriptionError(model.description)) || - Option.isSome(stepsError(model.steps)) || - repeat === null || - !isRepeat(repeat) - ) - return Option.none() - return Option.flatMap(steps, (all) => - Arr.isReadonlyArrayNonEmpty(all) - ? Option.some({ - name: Str.trim(model.name), - description: Str.isEmpty(Str.trim(model.description)) - ? null - : Str.trim(model.description), - steps: all, - repeat_count: repeat, - }) - : Option.none(), - ) -} - -// COMMANDS - -export const SaveEscalationChain = Command.define("SaveEscalationChain", { - args: { - openCount: Schema.Number, - editingId: Schema.NullOr(EscalationChainId), - payload: V2EscalationChainCreateParams, - }, - messages: [Message.CompletedSave], - execute: ({ openCount, editingId, payload }) => - saveEscalationChain(editingId, payload).pipe( - Effect.result, - Effect.map((result) => Message.CompletedSave({ openCount, result })), - ), -}) - -// UPDATE - -const liftDialog = (model: Model, result: ReturnType): Return => ({ - model: { ...model, dialog: result.model }, - commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), -}) - -const open = ( - model: Model, - fields: Pick, -): Return => - liftDialog( - { - ...model, - ...fields, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: model.openCount + 1, - }, - Dialog.open(model.dialog), - ) - -export const openNew = (model: Model): Return => - open(model, { - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - }) - -export const openEdit = (model: Model, chain: V2EscalationChain): Return => - open(model, { - editingId: chain.id, - name: chain.name, - description: chain.description ?? "", - steps: Arr.map(chain.steps, (step, index) => ({ - key: `s${index}`, - targets: step.targets, - waitMinutes: String(step.wait_minutes), - })), - repeatCount: String(chain.repeat_count), - nextKey: chain.steps.length, - }) - -const editStep = (model: Model, key: string, edit: (step: StepDraft) => StepDraft): Return => ({ - model: { ...model, steps: Arr.map(model.steps, (step) => (step.key === key ? edit(step) : step)) }, -}) - -const moveStep = (steps: ReadonlyArray, key: string, direction: "up" | "down") => { - const from = Arr.findFirstIndex(steps, (step) => step.key === key) - if (Option.isNone(from)) return steps - const to = direction === "up" ? from.value - 1 : from.value + 1 - const moving = steps[from.value] - const other = steps[to] - if (moving === undefined || other === undefined) return steps - return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) -} - -const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { - const id = menuIdOf(model, key) - const result = Menu.update(model.menus[key] ?? Menu.init(id), message) - const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } - const commands = Command.mapMessages(result.commands ?? [], (child) => - Message.GotStepMenuMessage({ key, message: child }), - ) - const picked = Option.flatMap( - result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), - targetOfKey, - ) - // The menu still opens and closes while saving, but a pick no longer edits the step. - return Option.match(model.isSaving ? Option.none() : picked, { - onNone: () => ({ model: next, commands }), - onSome: (target) => ({ - ...editStep(next, key, (step) => - Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) - ? step - : { ...step, targets: [...step.targets, target] }, - ), - commands, - }), - }) -} - -const save = (model: Model): Return => - model.isSaving - ? { model } - : Option.match(payloadOf(model), { - onNone: () => ({ model: { ...model, isShowingErrors: true } }), - onSome: (payload) => ({ - model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, - commands: [ - SaveEscalationChain({ - openCount: model.openCount, - editingId: model.editingId, - payload, - }), - ], - }), - }) - -/** A form edit is dropped while a save is in flight: the saved chain is what the dialog shows. */ -const unlessSaving = (model: Model, edit: () => Return): Return => (model.isSaving ? { model } : edit()) - -export const update = (model: Model, message: Message): Return => - Message.match(message, { - GotDialogMessage: ({ message: child }) => - // The dialog stays open while a save is in flight. - model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), - ChangedName: ({ value }) => unlessSaving(model, () => ({ model: { ...model, name: value } })), - ChangedDescription: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, description: value } })), - ChangedRepeatCount: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, repeatCount: value } })), - ChangedWait: ({ key, value }) => - unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, waitMinutes: value }))), - ClickedAddStep: () => - unlessSaving(model, () => - model.steps.length >= MAX_ESCALATION_STEPS - ? { model } - : { - model: { - ...model, - steps: [...model.steps, emptyStep(model.nextKey)], - nextKey: model.nextKey + 1, - }, - }, - ), - ClickedRemoveStep: ({ key }) => - unlessSaving(model, () => ({ - model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, - })), - ClickedMoveStep: ({ key, direction }) => - unlessSaving(model, () => ({ - model: { ...model, steps: moveStep(model.steps, key, direction) }, - })), - GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), - ClickedRemoveTarget: ({ key, target }) => - unlessSaving(model, () => - editStep(model, key, (step) => ({ - ...step, - targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), - })), - ), - ClickedSave: () => save(model), - CompletedSave: ({ openCount, result }) => - openCount !== model.openCount - ? { model } - : Result.match(result, { - onSuccess: (chain) => ({ - ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), - outMessage: OutMessage.SavedChain({ chain, isNew: model.editingId === null }), - }), - onFailure: (failure) => ({ - model: { ...model, isSaving: false, saveError: failure.message }, - }), - }), - }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts deleted file mode 100644 index b0bc3107ea..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import type { V2EscalationTarget, V2OnCallSchedule } from "@maple/domain/http/v2" -import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" -import { Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" -import { button } from "@maple/views/ui/button" -import { - fieldDescription, - fieldError, - fieldErrorText, - type FieldIds, - fieldLabel, - input, -} from "@maple/views/ui/field" -import { formDialog } from "@maple/views/ui/form-dialog" -import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" -import type { FieldState } from "@maple/views/ui/input" -import * as Menu from "@maple/views/ui/menu" -import type { MenuEntry } from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { personAvatar } from "@maple/views/ui/person-avatar" -import { hstack, vstack } from "@maple/views/ui/stack" -import { textarea } from "@maple/views/ui/textarea" -import { typography } from "@maple/views/ui/typography" -import { type PickedEntry, pickList } from "../../kit/pick-list" -import { formatWait } from "../shared/escalation-chains" -import { memberName, memberOf, type OrgMember } from "../shared/org-members" -import { - descriptionError, - targetKey, - menuIdOf, - Message, - type Model, - nameError, - numberOf, - repeatError, - type StepDraft, - stepsError, - targetsError, - waitError, -} from "./dialog" - -type H = HtmlBuilder - -export type ViewInputs = Readonly<{ - id: string - /** The synced destinations steps pick from; `null` until they arrive. */ - destinations: ReadonlyArray | null - /** The org's members a step can page; `null` until they load. */ - members: ReadonlyArray | null - /** The on-call schedules a step can page; `null` until they load. */ - schedules: ReadonlyArray | null -}> - -const nameOr = ( - rows: ReadonlyArray | null, - id: string, - fallback: string, -): string => - Option.getOrElse( - Option.map( - Arr.findFirst(rows ?? [], (row) => row.id === id), - (row) => row.name, - ), - () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), - ) - -/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ -const targetEntry = ( - h: H, - inputs: ViewInputs, - key: string, - target: V2EscalationTarget, -): PickedEntry => { - const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) - switch (target.type) { - case "destination": { - const name = nameOr(inputs.destinations, target.destination_id, "Destination") - return { - key: targetKey(target), - label: name, - media: icon(h, "BellIcon", { className: "text-muted-foreground" }), - removeLabel: `Stop notifying ${name}`, - onRemove, - } - } - case "user": { - const name = memberName(inputs.members, target.user_id) - return { - key: targetKey(target), - label: name, - media: personAvatar(h, { - name, - size: "xs", - imageUrl: Option.getOrUndefined( - Option.map(memberOf(inputs.members, target.user_id), (member) => member.imageUrl), - ), - }), - detail: "email and chat DM", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - case "schedule": { - const name = nameOr(inputs.schedules, target.schedule_id, "Schedule") - return { - key: targetKey(target), - label: name, - media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), - detail: "whoever is on call", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - } -} - -/** The "Add target" menu: destinations, people and schedules not yet on the step, by kind. */ -const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray => { - const picked = new Set(Arr.map(step.targets, targetKey)) - const group = ( - label: string, - items: ReadonlyArray>, - ): ReadonlyArray => { - const open = Arr.filter(items, (item) => !picked.has(item.value)) - return open.length === 0 - ? [] - : [ - { - _tag: "Group", - label, - entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), - }, - ] - } - return [ - ...group( - "Destinations", - Arr.map(inputs.destinations ?? [], (row) => ({ - value: `destination:${row.id}`, - label: row.name, - })), - ), - ...group( - "People", - Arr.map(inputs.members ?? [], (member) => ({ - value: `user:${member.userId}`, - label: member.name, - })), - ), - ...group( - "Whoever is on call on", - Arr.map(inputs.schedules ?? [], (schedule) => ({ - value: `schedule:${schedule.id}`, - label: schedule.name, - })), - ), - ] -} - -const stateOf = (value: string, isInvalid: boolean): FieldState => ({ - isInvalid, - isDirty: false, - isFilled: value !== "", - isFocused: false, - isTouched: false, -}) - -const idsOf = (id: string, name: string, hasDescription = false): FieldIds => ({ - control: `${id}-${name}`, - label: `${id}-${name}-label`, - error: `${id}-${name}-error`, - ...(hasDescription ? { description: `${id}-${name}-description` } : undefined), -}) - -/** A field's error once a save was tried, or at once for a number typed out of range. */ -const shownError = (model: Model, error: Option.Option, isImmediate = false): Option.Option => - model.isShowingErrors || isImmediate ? error : Option.none() - -const errorLine = ( - h: H, - ids: FieldIds, - state: FieldState, - error: Option.Option, -): ReadonlyArray => - Option.match(error, { - onNone: () => [], - onSome: (text) => [fieldError(h, { ids, state, attributes: [h.Role("alert")] }, [text])], - }) - -const textField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - placeholder: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: options.ids, state }, options.label), - input(h, { - ids: options.ids, - state, - isDisabled: options.isDisabled, - attributes: [ - h.Value(options.value), - h.Placeholder(options.placeholder), - h.OnInput(options.onInput), - h.Attribute("autocomplete", "off"), - ], - }), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -/** A short number input on one line with its label. */ -const numberField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - min: number - max: number - description?: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - hstack(h, { gap: "3", wrap: true }, [ - fieldLabel(h, { ids: options.ids, state, className: "font-normal" }, options.label), - input(h, { - ids: options.ids, - state, - className: "w-20", - isDisabled: options.isDisabled, - attributes: [ - h.Type("number"), - h.Min(String(options.min)), - h.Attribute("max", String(options.max)), - h.Attribute("step", "1"), - h.Value(options.value), - h.OnInput(options.onInput), - ], - }), - ]), - ...(options.description === undefined - ? [] - : [fieldDescription(h, { ids: options.ids, state }, [options.description])]), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) - -// Numbered, so each step's wait input has its own accessible name. -const waitLabel = (model: Model, index: number): string => - `Step ${index + 1}: ${ - index < model.steps.length - 1 - ? "then wait, in minutes" - : (numberOf(model.repeatCount) ?? 0) > 0 - ? "wait before repeating, in minutes" - : // The run ends as soon as the last step pages, so this wait only matters once it repeats. - "wait before repeating, in minutes (unused unless the chain repeats)" - }` - -const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { - const count = model.steps.length - const previous = index === 0 ? undefined : model.steps[index - 1] - const wait = waitError(step.waitMinutes) - const targets = shownError(model, targetsError(step.targets)) - const key = step.key - return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ - hstack(h, { gap: "2", justify: "between" }, [ - hstack(h, { gap: "2", shrink: true }, [ - typography(h, { size: "sm", weight: "medium", className: "shrink-0 whitespace-nowrap" }, [ - `Step ${index + 1}`, - ]), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - previous === undefined - ? "pages at once" - : Option.isNone(waitError(previous.waitMinutes)) - ? `after ${formatWait(Number(previous.waitMinutes))} without an ack` - : "after the wait above", - ]), - ]), - hstack(h, { gap: "0.5" }, [ - iconButton( - h, - { - label: `Move step ${index + 1} up`, - isDisabled: model.isSaving || index === 0, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], - }, - [icon(h, "ArrowUpIcon")], - ), - iconButton( - h, - { - label: `Move step ${index + 1} down`, - isDisabled: model.isSaving || index === count - 1, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], - }, - [icon(h, "ArrowDownIcon")], - ), - iconButton( - h, - { - label: `Remove step ${index + 1}`, - isDisabled: model.isSaving || count === 1, - attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], - }, - [icon(h, "TrashIcon")], - ), - ]), - ]), - pickList(h, { - entries: Arr.map(step.targets, (target) => targetEntry(h, inputs, key, target)), - menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), - toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), - addEntries: addEntries(inputs, step), - addLabel: "Add destination, person or schedule", - isDisabled: model.isSaving, - context: `step ${index + 1}`, - emptyText: "Pick who this step pages", - }), - ...Option.match(targets, { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - numberField(h, { - ids: idsOf(model.dialog.id, `wait-${key}`), - label: waitLabel(model, index), - value: step.waitMinutes, - min: 1, - max: MAX_ESCALATION_WAIT_MINUTES, - error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedWait({ key, value }), - }), - ]) -} - -const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => - vstack(h, { gap: "3" }, [ - hstack(h, { gap: "2", justify: "between" }, [ - typography(h, { size: "sm", weight: "medium" }, ["Steps"]), - typography(h, { size: "xs", tone: "muted", numeric: true }, [ - `${model.steps.length} of ${MAX_ESCALATION_STEPS}`, - ]), - ]), - ...Arr.map(model.steps, (step, index) => stepBlock(h, model, inputs, step, index)), - ...Option.match(shownError(model, stepsError(model.steps)), { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - button( - h, - { - variant: "outline", - size: "sm", - className: "self-start", - isDisabled: model.isSaving || model.steps.length >= MAX_ESCALATION_STEPS, - attributes: [h.OnClick(Message.ClickedAddStep())], - }, - [icon(h, "PlusIcon"), "Add step"], - ), - ]) - -const descriptionField = (h: H, model: Model): Html => { - const ids = idsOf(model.dialog.id, "description") - const error = shownError(model, descriptionError(model.description)) - const state = stateOf(model.description, Option.isSome(error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids, state }, "Description"), - textarea(h, { - id: ids.control, - defaultValue: model.description, - isDisabled: model.isSaving, - attributes: [ - // Uncontrolled: keyed on the open, so a reopened dialog rebuilds it with that chain's text. - h.Key(`${ids.control}-${model.openCount}`), - h.AriaLabelledBy(ids.label), - h.Placeholder("Optional: who is on this chain and when to change it"), - h.Rows(2), - h.OnInput((value) => Message.ChangedDescription({ value })), - ], - }), - ...errorLine(h, ids, state, error), - ]) -} - -/** The escalation chain dialog: name, ordered steps of targets and waits, and repeats. */ -export const view = Submodel.defineView((model, inputs, h) => { - const repeat = repeatError(model.repeatCount) - const isEditing = model.editingId !== null - return formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-xl", - title: isEditing ? "Edit escalation chain" : "New escalation chain", - description: - "Each step pages its destinations, people and whoever is on call, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", - panelClassName: "space-y-5", - fields: [ - textField(h, { - ids: idsOf(model.dialog.id, "name"), - label: "Name", - value: model.name, - placeholder: "Checkout on-call", - error: shownError(model, nameError(model.name)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedName({ value }), - }), - descriptionField(h, model), - stepsSection(h, model, inputs), - numberField(h, { - ids: idsOf(model.dialog.id, "repeat", true), - label: "Repeat the whole chain", - value: model.repeatCount, - min: 0, - max: MAX_ESCALATION_REPEATS, - description: `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedRepeatCount({ value }), - }), - ...(model.saveError === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, [isEditing ? "Couldn't save chain" : "Couldn't create chain"]), - alertDescription(h, [model.saveError]), - ]), - ]), - ], - onSubmit: Message.ClickedSave(), - submitLabel: isEditing ? "Save chain" : "Create chain", - isPending: model.isSaving, - }) -}) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index f7adc99b7f..b7756ceb63 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -1,24 +1,11 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" import { EscalationChainId } from "@maple/domain/http" -import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" +import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - selector, - submit, - text, - withViewInputs, -} from "foldkit/scene" +import { expect as expectView, given, Mount, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" @@ -61,12 +48,6 @@ const onRouting = (grants: ReturnType, chains: ReadonlyA ) } -const openDialog = () => [ - Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), - Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), -] - describe("alerts hub, escalation chains", () => { test("an org without chains learns what one is and can create the first", () => { scene( @@ -74,7 +55,10 @@ describe("alerts hub, escalation chains", () => { given(onRouting(ENGINEER, [])), expectView(text("No escalation chains yet")).toExist(), expectView(text(/waits for someone to acknowledge, then pages the next/u)).toExist(), - expectView(role("button", { name: "Create chain" })).toExist(), + expectView(role("link", { name: "Create chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) @@ -91,7 +75,11 @@ describe("alerts hub, escalation chains", () => { expectView(text("Used by 2 rules")).toExist(), expectView(text("Not used by any rule")).toExist(), expectView(role("button", { name: "Actions for Checkout on-call" })).toExist(), - expectView(role("button", { name: "New chain" })).toExist(), + expectView(role("link", { name: "New chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), + expectView(role("link", { name: "Checkout on-call" })).toExist(), ) }) @@ -107,7 +95,7 @@ describe("alerts hub, escalation chains", () => { scene( { update: pageUpdate, view }, given(onRouting(MEMBER, [])), - expectView(role("button", { name: "Create chain" })).not.toExist(), + expectView(role("link", { name: "Create chain" })).not.toExist(), expectView(text(/Ask an org admin to create one/u)).toExist(), ) scene( @@ -117,17 +105,55 @@ describe("alerts hub, escalation chains", () => { ) }) - test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { + test("Edit in a chain's menu opens its editor page", () => { + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const picked = pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(chainId), + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: "edit" } }), + }), + ) + expect(picked.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: expect.stringMatching(/^\/alerts\/escalation-chains\/esc_/u), + }) + }) + + test("Delete on a chain that rules start lists them and holds the delete, as its own page does", () => { + const askToDelete = (n: number) => + pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(Schema.decodeSync(EscalationChainId)(chainUuid(n))), + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ).model + const confirm = within(role("alertdialog"), role("button", { name: "Delete chain" })) scene( { update: pageUpdate, view }, - given(onRouting(ENGINEER, CHAINS)), - click(role("button", { name: "New chain" })), - ...openDialog(), - expectView(role("dialog", { name: "New escalation chain" })).toExist(), - submit(selector("form")), - Command.expectNone(), - expectView(text("Name the chain")).toExist(), - expectView(text("Pick at least one destination, person or schedule")).toExist(), + given(askToDelete(1)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(within(role("alertdialog"), text("Checkout errors"))).toExist(), + expectView(within(role("alertdialog"), text("Checkout p95"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // Before the rules list has loaded they are counted instead of named, and still hold it. + scene( + { update: pageUpdate, view }, + given({ ...askToDelete(1), rules: null }), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(within(role("alertdialog"), text("2 rules"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // A chain no rule starts deletes as before. + scene( + { update: pageUpdate, view }, + given(askToDelete(3)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/Escalations already running finish on their own copy/u)).toExist(), + expectView(confirm).not.toBeDisabled(), ) }) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..12b9a4da52 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -30,7 +30,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -110,8 +109,6 @@ export const Message = defineMessageUnion({ ClickedRetrySlos: {}, CompletedFetchChains: { key: Schema.String, result: outcome(Schema.Array(V2EscalationChain)) }, ClickedRetryChains: {}, - ClickedNewChain: {}, - GotChainDialogMessage: { message: ChainDialog.Message }, ClickedConfirmDeleteChain: {}, CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, GotDeleteChainDialogMessage: { message: Dialog.Message }, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index fb584a2b40..43a02b27c9 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -32,7 +32,6 @@ import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -106,7 +105,6 @@ export const Model = Schema.Struct({ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), /** Routing tab: `escalationChains.list`, read only while the tab is open. */ chains: KeyedSchema(Schema.Array(V2EscalationChain), LoadFailure), - chainDialog: ChainDialog.Model, /** The chain awaiting delete confirmation, the delete in flight, and why the last one was refused. */ confirmDeleteChainId: Schema.NullOr(EscalationChainId), deletingChainId: Schema.NullOr(EscalationChainId), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index c559c3f07c..6a1e43f1e3 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -23,13 +23,12 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" -import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" +import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT, chainHref } from "../shared/escalation-chains" import { editScheduleHref, handoffMs, @@ -89,7 +88,6 @@ const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" -export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" export const WINDOW_DIALOG_ID = "alerts-maintenance-window" const DELETE_WINDOW_DIALOG_ID = "alerts-delete-window" @@ -337,7 +335,6 @@ export const init = (route: HubRoute, context: PageContext): Return => { deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), chains: Keyed.idle(), - chainDialog: ChainDialog.init(CHAIN_DIALOG_ID), confirmDeleteChainId: null, deletingChainId: null, deleteChainError: null, @@ -503,28 +500,6 @@ const refreshChains = (model: Model, context: PageContext): Return => { return { model: schedules.model, commands: [...chains.commands, ...schedules.commands] } } -/** The chain dialog's outcome: a toast, and the list read again. */ -const liftChainDialog = ( - model: Model, - context: PageContext, - step: ReturnType, -): Return => { - const lifted: Model = { ...model, chainDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotChainDialogMessage({ message }), - ) - if (step.outMessage === undefined) return { model: lifted, commands } - const refreshed = refreshChains(lifted, context) - return { - model: refreshed.model, - commands: [...commands, ...(refreshed.commands ?? [])], - outMessage: toast( - "success", - step.outMessage.isNew ? "Escalation chain created" : "Escalation chain saved", - ), - } -} - /** The names of the rules that start `chain` that this tab has loaded; the rest are counted, not named. */ export const chainRuleNames = ( model: Pick, @@ -575,7 +550,7 @@ const updateDeleteChainDialog = (model: Model, step: ReturnType +const pickedChainAction = (model: Model, rawId: string, value: string): Return => Option.match( Array.findFirst(chainsOf(model), (chain) => chain.id === rawId), { @@ -591,7 +566,13 @@ const pickedChainAction = (model: Model, context: PageContext, rawId: string, va }, Dialog.open(model.deleteChainDialog), ) - : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: chainHref(chain.id), + isReplace: false, + }), + }, }, ) @@ -1048,7 +1029,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu const [kind, ...rest] = Str.split(":")(menuId) const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) - if (kind === "chain") return pickedChainAction(model, context, target, value) + if (kind === "chain") return pickedChainAction(model, target, value) if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "window") return pickedWindowAction(model, context, target, value) if (kind === "rule") { @@ -1307,9 +1288,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re } }, ClickedRetryChains: () => refreshChains(model, context), - ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), - GotChainDialogMessage: ({ message: child }) => - liftChainDialog(model, context, ChainDialog.update(model.chainDialog, child)), ClickedConfirmDeleteChain: () => confirmDeleteChain(model), CompletedDeleteChain: ({ chainId, result }) => deletedChain(model, context, chainId, result), GotDeleteChainDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index b533835c0e..42838fa2b6 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -6,7 +6,7 @@ import { Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { borderedBox } from "@maple/views/ui/bordered-box" -import { button } from "@maple/views/ui/button" +import { linkButton } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" @@ -18,10 +18,12 @@ import { emptyActions } from "@maple/views/ui/signal-empty-state" import { skeleton } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" -import { chainSummary } from "../shared/escalation-chains" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" +import { chainHref, chainSummary, newChainHref } from "../shared/escalation-chains" import { memberName } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -89,7 +91,14 @@ export const chainRow = ( hstack(h, { gap: "3", align: "start" }, [ rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ - typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), + typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [ + // `block truncate`: an inline-flex link would be clipped without an ellipsis. + textLink( + h, + { href: chainHref(chain.id), tone: "inherit", className: "block truncate" }, + [chain.name], + ), + ]), typography( h, { @@ -150,7 +159,7 @@ const chainsEmpty = (h: H, model: Model): Html => ...(canManage(model) ? [ emptyActions(h, [ - button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewChain())] }, [ + linkButton(h, { size: "sm", href: newChainHref() }, [ icon(h, "PlusIcon"), "Create chain", ]), @@ -211,15 +220,10 @@ export const chainsSection = (h: H, model: Model): Html => title: "Escalation chains", actions: canManage(model) && hasChains(model) - ? button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewChain())], - }, - [icon(h, "PlusIcon"), "New chain"], - ) + ? linkButton(h, { size: "sm", variant: "outline", href: newChainHref() }, [ + icon(h, "PlusIcon"), + "New chain", + ]) : undefined, }), typography(h, { size: "sm", tone: "muted", as: "p" }, [ @@ -231,7 +235,10 @@ export const chainsSection = (h: H, model: Model): Html => ], ) -/** "Delete {chain}?": the 409 that names the rules holding it stays in the dialog. */ +/** + * "Delete {chain}?": as on the chain's page, the rules that start it are listed and hold the + * delete. A 409 from a rule attached since the list was read stays in the dialog. + */ export const deleteChainDialog = (h: H, model: Model): Html => { const chain = Arr.findFirst( Option.getOrElse(AsyncData.getData(model.chains.data), () => []), @@ -241,24 +248,29 @@ export const deleteChainDialog = (h: H, model: Model): Html => { Option.map(chain, (found) => found.name), () => "escalation chain", ) + const confirm = chainDeleteConfirm(h, chain, model.rules) + const children = [ + ...confirm.ruleList, + ...(model.deleteChainError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteChainError]), + ]), + ]), + ] return confirmDialog(h, { slotId: "alerts-delete-chain", model: model.deleteChainDialog, toParentMessage: (message) => Message.GotDeleteChainDialogMessage({ message }), title: `Delete ${name}?`, - description: "Escalations already running finish on their own copy. This cannot be undone.", - children: - model.deleteChainError === null - ? undefined - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Couldn't delete chain"]), - alertDescription(h, [model.deleteChainError]), - ]), - ], + description: confirm.description, + children: children.length === 0 ? undefined : children, confirmLabel: "Delete chain", isPending: model.deletingChainId !== null, + isConfirmDisabled: confirm.isInUse, onConfirm: Message.ClickedConfirmDeleteChain(), }) } diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 7278c9c6c1..e02ef144ba 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -38,9 +38,8 @@ import { anomalyInbox, inboxFilters } from "./view-anomalies" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" -import * as ChainDialogView from "../escalation-dialog/view" import { deleteChainDialog } from "./view-chains" -import { CHAIN_DIALOG_ID, WINDOW_DIALOG_ID } from "./update" +import { WINDOW_DIALOG_ID } from "./update" import * as SilenceDialogView from "../silence-dialog/view" import { silencesTab } from "./view-silences" import { deleteWindowDialog, hasWindows, maintenanceTab } from "./view-maintenance" @@ -380,19 +379,6 @@ export const view = Submodel.defineView((model, deleteChainDialog(h, model), deleteScheduleDialog(h, model), deleteWindowDialog(h, model), - h.submodel({ - slotId: CHAIN_DIALOG_ID, - model: model.chainDialog, - view: ChainDialogView.view, - viewInputs: { - id: CHAIN_DIALOG_ID, - destinations: model.destinations, - members: model.members, - // `null` until the schedules load (or after a failed read): names read neutrally. - schedules: Option.getOrNull(AsyncData.getData(model.schedules.data)), - }, - toParentMessage: (message) => Message.GotChainDialogMessage({ message }), - }), h.submodel({ slotId: "alerts-destination", model: model.destinationDialog, diff --git a/apps/web/src/page/alerts/shared/chain-delete-confirm.ts b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts new file mode 100644 index 0000000000..2f78c960ff --- /dev/null +++ b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts @@ -0,0 +1,69 @@ +import type { V2EscalationChain } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { typography } from "@maple/views/ui/typography" + +export type ChainDeleteConfirm = Readonly<{ + /** A rule still starts the chain: the API refuses the delete, so the confirm holds it. */ + isInUse: boolean + description: string + /** The rules that start it by name; ones this tab has not loaded are counted. */ + ruleList: ReadonlyArray +}> + +/** + * What a chain's delete confirm says, on its own page and on its Routing row: the rules that + * start it, which the API makes move off it first, and Delete held while there are any. + */ +export const chainDeleteConfirm = ( + h: HtmlBuilder, + chain: Option.Option>, + rules: ReadonlyArray<{ readonly id: string; readonly name: string }> | null, +): ChainDeleteConfirm => { + const ruleIds: ReadonlyArray = Option.match(chain, { + onNone: () => [], + onSome: (found) => found.rule_ids, + }) + const names = Arr.map( + Arr.filter(rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), + (rule) => rule.name, + ) + if (ruleIds.length === 0) + return { + isInUse: false, + description: "Escalations already running finish on their own copy. This cannot be undone.", + ruleList: [], + } + return { + isInUse: true, + description: + ruleIds.length === 1 + ? "This rule would stop escalating. Remove the chain from its severity tiers first." + : "These rules would stop escalating. Remove the chain from their severity tiers first.", + ruleList: [ + h.ul( + [h.Class("list-disc space-y-1 pl-5")], + [ + ...Arr.map(names, (ruleName) => + h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), + ), + ...(ruleIds.length > names.length + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + names.length === 0 + ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` + : `and ${ruleIds.length - names.length} more`, + ]), + ], + ), + ] + : []), + ], + ), + ], + } +} diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index c2c01db6b0..e9482b8cb2 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -5,13 +5,14 @@ import type { OnCallScheduleId, UserId, } from "@maple/domain/http" -import type { - V2AlertIncidentEscalation, - V2EscalationChain, - V2EscalationChainCreateParams, +import { + EscalationChainPublicId, + type V2AlertIncidentEscalation, + type V2EscalationChain, + type V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect, Option } from "effect" +import { Array as Arr, Effect, Option, Schema } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -31,6 +32,12 @@ export const listEscalationChains = Effect.gen(function* () { return page.data }).pipe(Effect.mapError(toLoadFailure)) +export const retrieveEscalationChain = (id: EscalationChainId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.escalationChains.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + /** Creates a chain, or replaces `editingId`'s fields with the form's. */ export const saveEscalationChain = ( editingId: EscalationChainId | null, @@ -62,6 +69,20 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) +const encodeChainId = Schema.encodeSync(EscalationChainPublicId) +const decodeChainId = Schema.decodeUnknownOption(EscalationChainPublicId) + +/** The `esc_…` id in a URL as the internal id. */ +export const chainIdFromUrl = (raw: string): Option.Option => decodeChainId(raw) + +export const chainHref = (id: EscalationChainId): string => + hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search: {} })) + +export const newChainHref = (): string => hrefOf(AppRoute.AlertsEscalationChainNew({ search: {} })) + +/** 404 on a read or a write: the chain was deleted elsewhere. */ +export const CHAIN_NOT_FOUND_CODE = "escalation_chain_not_found" + // SUMMARY /** `10m`, `1h`, `1h 30m`, `1d`. */ @@ -97,8 +118,18 @@ export type TargetNames = Readonly<{ * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. */ +export type ChainOutline = Readonly<{ + steps: ReadonlyArray< + Readonly<{ + targets: ReadonlyArray + wait_minutes: number + }> + > + repeat_count: number +}> + export const chainSummary = ( - chain: Pick, + chain: ChainOutline, nameOf: (id: AlertDestinationId) => string, people: TargetNames = {}, ): string => { diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts index 92651bae91..506c314207 100644 --- a/apps/web/src/page/alerts/shared/org-members.ts +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -72,7 +72,12 @@ export const listOrgMembers: Effect.Effect, string> = c * A member by id; someone who left the org reads as "A former member". Until the members load, after * a failed read, or in an org past `MAX_MEMBERS`, an unknown id reads neutrally as "Member". */ -export const memberName = (members: ReadonlyArray | null, userId: string): string => +export const memberName = ( + members: ReadonlyArray | null, + userId: string, + /** What someone who left the org reads as, where the default does not fit. */ + formerName = "A former member", +): string => members === null || members.length >= MAX_MEMBERS ? Option.getOrElse( Option.map( @@ -86,7 +91,7 @@ export const memberName = (members: ReadonlyArray | null, userId: str Arr.findFirst(members, (member) => member.userId === userId), (member) => member.name, ), - () => "A former member", + () => formerName, ) export const memberOf = ( diff --git a/apps/web/src/page/kit/chain-connector.ts b/apps/web/src/page/kit/chain-connector.ts new file mode 100644 index 0000000000..3fd1a75319 --- /dev/null +++ b/apps/web/src/page/kit/chain-connector.ts @@ -0,0 +1,45 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" + +// `ChainConnector`: the short line that joins two cards of a vertical chain. + +/** + * `ChainConnector`: the line between two cards of a vertical chain, with a + on it that adds a card + * there. The + shows while the gap is hovered or holds focus, and always on a touch screen or + * when `isPinned` (the last gap, so a mouse user can see that the chain takes another card). + */ +export const chainConnector = ( + h: HtmlBuilder, + options: Readonly<{ + /** The + button's id (a focus target) and accessible name; `null` draws the line alone. */ + insert: Readonly<{ id: string; label: string; onClick: M; isPinned?: boolean }> | null + attributes?: ReadonlyArray> + }>, +): Html => + h.div( + [h.Class("group/connector relative flex h-6 justify-center"), ...(options.attributes ?? [])], + [ + h.div([h.Class("h-full w-px bg-border"), h.AriaHidden(true)], []), + ...(options.insert === null + ? [] + : [ + iconButton( + h, + { + label: options.insert.label, + variant: "outline", + size: "icon-xs", + className: cn( + "absolute top-1/2 -translate-y-1/2", + options.insert.isPinned !== true && + "opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + ), + attributes: [h.Id(options.insert.id), h.OnClick(options.insert.onClick)], + }, + [icon(h, "PlusIcon")], + ), + ]), + ], + ) diff --git a/apps/web/src/page/kit/pick-chips.ts b/apps/web/src/page/kit/pick-chips.ts new file mode 100644 index 0000000000..6bf72b6447 --- /dev/null +++ b/apps/web/src/page/kit/pick-chips.ts @@ -0,0 +1,134 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. + +/** One picked chip: its media (a logo, an avatar, an icon), its name and how to remove it. */ +export type PickedChip = Readonly<{ + key: string + label: string + media: Html + /** A muted note after the name ("Ben Brook on call"). */ + detail?: string + removeLabel: string + /** The remove button's id, for a form that moves focus to it when the chip before it goes. */ + removeId?: string + onRemove: M +}> + +const chip = (h: HtmlBuilder, entry: PickedChip, canEdit: boolean, isDisabled: boolean): Html => + hstack( + h, + { + gap: "1.5", + inline: true, + as: "span", + className: `h-7 max-w-full rounded-md border border-border bg-surface-sunken pl-1.5 ${canEdit ? "pr-1" : "pr-2"}`, + attributes: [h.Key(entry.key)], + }, + [ + entry.media, + typography(h, { size: "sm", truncate: true }, [entry.label]), + ...(entry.detail === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", className: "hidden whitespace-nowrap sm:inline" }, + [entry.detail], + ), + ]), + ...(canEdit + ? [ + button( + h, + { + variant: "ghost", + size: "icon-2xs", + isDisabled, + attributes: [ + ...(entry.removeId === undefined ? [] : [h.Id(entry.removeId)]), + h.AriaLabel(entry.removeLabel), + h.OnClick(entry.onRemove), + ], + }, + [icon(h, "XmarkIcon")], + ), + ] + : []), + ], + ) + +/** + * `PickChips`: what a form has picked as removable chips, then an "Add" chip that opens a menu of + * the rest; the chips and the button go inline on the caller's own wrapping line. + */ +export const pickChips = ( + h: HtmlBuilder, + options: Readonly<{ + entries: ReadonlyArray> + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + addEntries: ReadonlyArray + /** The add button's text while nothing is picked; after that it reads "Add". */ + addLabel: string + /** The add button's accessible name, when a form shows several pickers ("... to step 2"). */ + addAriaLabel?: string + /** A save in flight: the picks show, but nothing can be removed or added. */ + isDisabled?: boolean + /** A viewer who cannot edit: the picks alone, and `emptyText` when there are none. */ + isReadOnly?: boolean + emptyText: string + }>, +): ReadonlyArray => { + const isDisabled = options.isDisabled ?? false + const canEdit = !(options.isReadOnly ?? false) + return [ + ...Arr.map(options.entries, (entry) => chip(h, entry, canEdit, isDisabled)), + ...(canEdit + ? [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: options.addEntries, + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + button( + h, + { + variant: "dashed", + size: "xs", + isDisabled, + attributes: [ + h.AriaLabel(options.addAriaLabel ?? options.addLabel), + ...trigger, + ], + }, + [ + icon(h, "PlusIcon"), + options.entries.length === 0 ? options.addLabel : "Add", + ], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ] + : options.entries.length === 0 + ? [typography(h, { size: "sm", tone: "muted" }, [options.emptyText])] + : []), + ] +} diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 4c826a97ad..1deb10a79a 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -31,6 +31,7 @@ import * as AlertsSlo from "./alerts/slo" import * as AlertsSloNew from "./alerts/slo-form" import * as AlertsSchedule from "./alerts/schedule" import * as AlertsScheduleNew from "./alerts/schedule-form" +import * as AlertsEscalationChain from "./alerts/escalation-chain" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -126,6 +127,7 @@ export const pages = [ AlertsSloNew.page, AlertsSchedule.page, AlertsScheduleNew.page, + AlertsEscalationChain.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 4d03b0a0b6..47969e2b1a 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -37,6 +37,8 @@ export const AppRoute = defineRouteUnion({ AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, AlertsScheduleNew: { search: Search.AlertsScheduleNewSearch }, AlertsSchedule: { scheduleId: Schema.String, search: Search.AlertsScheduleSearch }, + AlertsEscalationChainNew: { search: Search.AlertsEscalationChainSearch }, + AlertsEscalationChain: { chainId: Schema.String, search: Search.AlertsEscalationChainSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 04c7c0d058..eda04755d6 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -122,6 +122,11 @@ describe("routes", () => { expect(parse("/alerts/uptime/mon_1")._tag).toBe("AlertsUptimeMonitor") expect(parse("/alerts/slos/slo_1")._tag).toBe("AlertsSlo") expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") + expect(parse("/alerts/escalation-chains/new")._tag).toBe("AlertsEscalationChainNew") + expect(parse("/alerts/escalation-chains/esc_1")).toMatchObject({ + _tag: "AlertsEscalationChain", + chainId: "esc_1", + }) expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") expect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 02f882e357..5b8d1b5536 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -121,6 +121,17 @@ const routers = { search(Search.AlertsScheduleSearch), Route.mapTo(AppRoute.AlertsSchedule), ), + AlertsEscalationChainNew: pipe( + path("alerts", "escalation-chains", "new"), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChainNew), + ), + AlertsEscalationChain: pipe( + path("alerts", "escalation-chains"), + slash(segment("chainId")), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChain), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -542,6 +553,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsSlo, routers.AlertsScheduleNew, routers.AlertsSchedule, + routers.AlertsEscalationChainNew, + routers.AlertsEscalationChain, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 052f15ac29..d680b33e10 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -389,6 +389,9 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) +/** `/alerts/escalation-chains/new` and `/:chainId`. */ +export const AlertsEscalationChainSearch = Schema.Struct({}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: StringParam, diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 067da40142..f0bae674fd 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -173,7 +173,13 @@ const alertsItem: NavItem = { alsoActive: ["/alerts/schedules"], startsGroup: true, }, - { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + { + title: "Routing", + href: "/alerts/routing", + icon: "BranchForkIcon", + paletteTitle: "Alert routing", + alsoActive: ["/alerts/escalation-chains"], + }, ], } diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index ae98087bc1..27a543acf9 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -46,6 +46,8 @@ const SAMPLE_PATHS = [ "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", "/alerts/schedules/new", "/alerts/schedules/onc_gQtGH2s65G6a7sJmuayXL6", + "/alerts/escalation-chains/new", + "/alerts/escalation-chains/esc_gQtGH2s65G6a7sJmuayXL6", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", diff --git a/packages/ui/src/components/ui/button.styles.ts b/packages/ui/src/components/ui/button.styles.ts index eef0df92c5..296ba2dbf2 100644 --- a/packages/ui/src/components/ui/button.styles.ts +++ b/packages/ui/src/components/ui/button.styles.ts @@ -30,6 +30,9 @@ export const buttonVariants = cva( "not-disabled:inset-shadow-highlight border-destructive bg-destructive text-on-fill shadow-raised hover:bg-destructive/90 active:bg-destructive/90 data-pressed:bg-destructive/90 dark:border-destructive/80 dark:bg-destructive/80 dark:hover:bg-destructive/72 dark:active:bg-destructive/72 dark:data-pressed:bg-destructive/72 *:data-[slot=button-loading-indicator]:text-on-fill [:active,[data-pressed]]:inset-shadow-pressed [:disabled,:active,[data-pressed]]:shadow-none", "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-raised not-disabled:not-active:not-data-pressed:before:shadow-control hover:border-destructive-a3 active:border-destructive-a3 hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:border-destructive-a3 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 [:disabled,:active,[data-pressed]]:shadow-none", + // A quiet delete beside other content ("Delete chain"): destructive ink, a wash only on hover. + "destructive-ghost": + "border-transparent text-destructive-foreground hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground", // Add-a-row affordances ("Add threshold", "New dashboard"): an outline that reads as an empty slot. dashed: "active:scale-100! border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", ghost: "border-transparent text-foreground hover:bg-accent active:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 35158abd0a..c554604cd1 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -182,6 +182,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/advanced-filter.ts` `advancedFilterButton`, `appliedWhereClause`, `advancedFilterDialog`, `whereClauseWarnings`: The pieces around a list page's advanced filter dialog (`/traces`, `/logs`). - `apps/web/src/page/kit/autocomplete-lookups.ts` `names`, `lookupField`, `autocompleteLookup`: The autocomplete lookups a query builder fetches once it is first focused, each reduced to the names it suggests. +- `apps/web/src/page/kit/chain-connector.ts` `chainConnector`: `ChainConnector`: the short line that joins two cards of a vertical chain. - `apps/web/src/page/kit/clipboard.ts` `writeClipboard`, `copyToast`: `useCopy`'s write: the clipboard API, then the `execCommand` fallback; `true` when it landed. - `apps/web/src/page/kit/commit-card.ts` `isResolvableSha`, `commitDetailKey`, `commitDetailsKey`, `readCommitDetail`, `readCommitDetails`, `loadCommitDetail`, `commitDetailBody`: The commit SHA hover card: the body `CommitDetailBody` renders once `commitQueryAtom(sha)` settles, and the lookup itself. - `apps/web/src/page/kit/compliance-line.ts` `compliance`: The compliance footnote under the plan pickers (DPA available, SOC 2 and ISO 27001 in progress). @@ -208,6 +209,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/nav-shell.ts` `navShell`: `NavShell`: a page's own nav rail of titled sections (settings, security, alerts). - `apps/web/src/page/kit/page-rail-sheet.ts` `pageRailSheet`: `PageLayout.FilterSidebar` / `RightSidebar` below `lg`: the page's rail in a side sheet (``) with an sr-only header. - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. +- `apps/web/src/page/kit/pick-chips.ts` `pickChips`: `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies.