t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.
Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
- validates caller == requested_by AND status = pending
- reuses the existing wider counter-allowlist (buildCounterSetClauses
from SuggestChanges) — every editable field on the entity, not just
the date triggers
- applies the field updates to the entity row via applyEntityUpdate
(including the event_type_ids junction rewrite for deadlines)
- merges new fields into approval_requests.payload (jsonb) so the
approver inbox sees what was revised
- emits a distinct *_approval_edited_by_requester project_event so the
Verlauf surfaces the revision separately from the original *_requested
row and any decision row
- request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
- Body: {"fields": {<entity-shape>}}
- Errors reuse the existing mapApprovalError mapping:
400 suggestion_requires_change, 403 not_authorized,
404, 409 request_not_pending
- Distinguishing audit event types per the spec:
- destructive Withdraw path: existing <entity>_approval_revoked
(no behaviour change — for CREATE deletes the entity, for UPDATE /
COMPLETE reverts to pre_image, for DELETE cancels the delete request)
- edit-instead path: new <entity>_approval_edited_by_requester
Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
- Built on the unified openModal() primitive (t-paliad-217 Slice A)
- Primary CTA "Termin bearbeiten" highlights the non-destructive path
- Secondary defaults to "Abbrechen" (handled by openModal)
- Destructive button "Endgültig zurückziehen und löschen" lives inside
the body (red, separated by a dashed border) so the safe path stays
visually primary in the footer
- Copy adapts per lifecycle:
CREATE → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
UPDATE → "Ihre vorgeschlagenen Änderungen werden verworfen."
DELETE → "Der Eintrag bleibt bestehen."
Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
- Replace confirm() in withdraw flow with openWithdrawWarningModal()
- Edit path: set module-level pendingEditMode = true + enter edit mode
(override existing pending-state freeze on appointments; expose
enterEdit() via late-bound pendingEnterEdit on deadlines)
- Save handler in pendingEditMode routes to /edit-entity instead of
PATCH /api/<entity>/{id} (which still 409s on pending state)
- Destructive Withdraw path: existing /revoke endpoint unchanged
- For CREATE-lifecycle revokes the entity is gone — bounce to the
/events list instead of trying to re-fetch (was reload() before)
i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)
CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.
Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)
Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
rewrite + Save pending-edit branch + form-freeze respects
pendingEditMode)
Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
150 lines
6.4 KiB
TypeScript
150 lines
6.4 KiB
TypeScript
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
|
//
|
|
// Before t-paliad-252 the deadline + appointment detail pages did a
|
|
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
|
// For pending CREATE lifecycles that endpoint silently DELETES the
|
|
// underlying entity row — m's "withdrawing the approval deletes the event"
|
|
// surprise.
|
|
//
|
|
// This modal replaces the confirm() with three explicit paths:
|
|
//
|
|
// 1. Cancel — does nothing
|
|
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
|
// through POST /approval-requests/{id}/
|
|
// edit-entity which keeps the request
|
|
// pending and merges the new fields
|
|
// into approval_request.payload
|
|
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
|
// löschen behaviour (delete for CREATE, revert
|
|
// for UPDATE/COMPLETE, cancel for
|
|
// DELETE-lifecycle requests)
|
|
//
|
|
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
|
// three-button row sits cleanly inside the body — the primitive only
|
|
// supports one secondary action, but we paint the destructive button as a
|
|
// separate row above the footer.
|
|
|
|
import { t } from "../i18n";
|
|
import { openModal } from "./modal";
|
|
|
|
export type WithdrawAction = "edit" | "withdraw";
|
|
|
|
export interface WithdrawWarningArgs {
|
|
// entityType drives the copy ("event" vs "appointment" labels).
|
|
entityType: "deadline" | "appointment";
|
|
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
|
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
|
// cancelling the deletion request).
|
|
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
|
}
|
|
|
|
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
|
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
|
export async function openWithdrawWarningModal(
|
|
args: WithdrawWarningArgs,
|
|
): Promise<WithdrawAction | null> {
|
|
const body = document.createElement("div");
|
|
body.className = "withdraw-warning-body";
|
|
|
|
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
|
// knows what the destructive button will actually do. The /revoke
|
|
// backend behaviour:
|
|
// - create → DELETE the entity (the "surprise" m flagged)
|
|
// - update → revert to pre_image
|
|
// - complete → revert to pre-complete state
|
|
// - delete → cancel the delete request (entity stays alive)
|
|
const intro = document.createElement("p");
|
|
intro.className = "withdraw-warning-intro";
|
|
intro.textContent = leadCopyFor(args);
|
|
body.appendChild(intro);
|
|
|
|
const sub = document.createElement("p");
|
|
sub.className = "withdraw-warning-sub muted";
|
|
sub.textContent = subCopyFor(args);
|
|
body.appendChild(sub);
|
|
|
|
// The destructive button lives inside the body — the openModal primitive
|
|
// only exposes one secondary button slot, and we want the safe "Edit"
|
|
// path to be the primary CTA. Painting it in red here, separated from
|
|
// the footer, signals "this is the dangerous option" without competing
|
|
// visually with the primary CTA.
|
|
const destructiveRow = document.createElement("div");
|
|
destructiveRow.className = "withdraw-warning-destructive-row";
|
|
const destructiveBtn = document.createElement("button");
|
|
destructiveBtn.type = "button";
|
|
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
|
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
|
destructiveRow.appendChild(destructiveBtn);
|
|
body.appendChild(destructiveRow);
|
|
|
|
return new Promise<WithdrawAction | null>((resolve) => {
|
|
let chosen: WithdrawAction | null = null;
|
|
|
|
// The destructive button has to close the modal and return "withdraw".
|
|
// We need access to the modal's internal close() — fortunately openModal
|
|
// exposes it via the primary handler's first arg. We pass through the
|
|
// outer resolve and let the primary handler (Edit) own the close-fn
|
|
// route. For the destructive button we resolve the outer promise
|
|
// directly and then synthesise an ESC keypress so the modal dismisses
|
|
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
|
// the modal already supports. (openModal's onClose fires on every
|
|
// dismiss path including the primary handler resolution.)
|
|
destructiveBtn.addEventListener("click", () => {
|
|
chosen = "withdraw";
|
|
// The unified openModal primitive (modal.ts) wires its dismiss path
|
|
// through the native <dialog>'s `cancel` event. Dispatching it on
|
|
// the parent <dialog> runs the same finish() → onClose → resolve
|
|
// sequence as ESC / backdrop. We then map the resolved `null` back
|
|
// to "withdraw" via the captured `chosen` in onClose below.
|
|
const dialogEl = body.closest("dialog");
|
|
dialogEl?.dispatchEvent(new Event("cancel"));
|
|
});
|
|
|
|
void openModal<WithdrawAction>({
|
|
title: t("approvals.withdraw.modal.title"),
|
|
body,
|
|
size: "md",
|
|
classNames: "withdraw-warning-modal",
|
|
primary: {
|
|
label: t("approvals.withdraw.primary.label"),
|
|
handler: (close) => {
|
|
chosen = "edit";
|
|
close("edit");
|
|
},
|
|
},
|
|
secondary: { label: t("approvals.withdraw.cancel") },
|
|
onClose: () => {
|
|
// Resolves whatever was chosen via the destructive button OR the
|
|
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
|
// null which is the right "cancel" semantics.
|
|
resolve(chosen);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
function leadCopyFor(args: WithdrawWarningArgs): string {
|
|
switch (args.lifecycleEvent) {
|
|
case "create":
|
|
return args.entityType === "appointment"
|
|
? t("approvals.withdraw.lead.create.appointment")
|
|
: t("approvals.withdraw.lead.create.deadline");
|
|
case "delete":
|
|
return t("approvals.withdraw.lead.delete");
|
|
default:
|
|
// update / complete / unknown → revert semantics
|
|
return t("approvals.withdraw.lead.update");
|
|
}
|
|
}
|
|
|
|
function subCopyFor(args: WithdrawWarningArgs): string {
|
|
switch (args.lifecycleEvent) {
|
|
case "create":
|
|
return t("approvals.withdraw.sub.create");
|
|
case "delete":
|
|
return t("approvals.withdraw.sub.delete");
|
|
default:
|
|
return t("approvals.withdraw.sub.update");
|
|
}
|
|
}
|