New edit-rule page created
This commit is contained in:
@@ -32,6 +32,8 @@ import {
|
|||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
setTransferPendingFlag,
|
setTransferPendingFlag,
|
||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
|
import { createFlowStateFromPublishedRule } from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
import { deleteServerDraft } from "../../../lib/create/api";
|
import { deleteServerDraft } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import {
|
import {
|
||||||
@@ -133,12 +135,23 @@ function CreateFlowLayoutContent({
|
|||||||
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
const loginReturnPath =
|
||||||
|
currentStep === "edit-rule"
|
||||||
|
? "/create/edit-rule?syncDraft=1"
|
||||||
|
: "/create/final-review?syncDraft=1";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
publishBannerMessage,
|
publishBannerMessage,
|
||||||
setPublishBannerMessage,
|
setPublishBannerMessage,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
finalize: handleFinalize,
|
finalize: handleFinalize,
|
||||||
} = useCreateFlowFinalize({ state, router, openLogin });
|
} = useCreateFlowFinalize({
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isTemplateReviewRoute,
|
isTemplateReviewRoute,
|
||||||
@@ -221,6 +234,34 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== "edit-rule") return;
|
||||||
|
const last = readLastPublishedRule();
|
||||||
|
if (!last) {
|
||||||
|
router.replace("/create/completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editingId = state.editingPublishedRuleId?.trim() ?? "";
|
||||||
|
if (editingId.length > 0 && editingId !== last.id) {
|
||||||
|
router.replace("/create/completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const titleOk =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
const sectionsClear = (state.sections?.length ?? 0) === 0;
|
||||||
|
/** Stale template `sections` (e.g. Values-only) makes final-review rows wrong; re-hydrate until cleared. */
|
||||||
|
if (titleOk && editingId === last.id && sectionsClear) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState(createFlowStateFromPublishedRule(last));
|
||||||
|
}, [
|
||||||
|
currentStep,
|
||||||
|
router,
|
||||||
|
updateState,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
|
state.title,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
setCommunitySaveMagicLinkSuccess(false);
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
@@ -260,7 +301,8 @@ function CreateFlowLayoutContent({
|
|||||||
|
|
||||||
const isCompletedStep = currentStep === "completed";
|
const isCompletedStep = currentStep === "completed";
|
||||||
const isRightRailStep = currentStep === "decision-approaches";
|
const isRightRailStep = currentStep === "decision-approaches";
|
||||||
const isFinalReviewStep = currentStep === "final-review";
|
const isFinalReviewLike =
|
||||||
|
currentStep === "final-review" || currentStep === "edit-rule";
|
||||||
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||||
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||||
const isSelectSplitScrollStep =
|
const isSelectSplitScrollStep =
|
||||||
@@ -275,7 +317,7 @@ function CreateFlowLayoutContent({
|
|||||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||||
: isSelectSplitScrollStep
|
: isSelectSplitScrollStep
|
||||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||||
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
|
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute
|
||||||
? "items-start justify-center overflow-y-auto"
|
? "items-start justify-center overflow-y-auto"
|
||||||
: "items-start justify-center overflow-y-auto md:items-center";
|
: "items-start justify-center overflow-y-auto md:items-center";
|
||||||
|
|
||||||
@@ -289,7 +331,8 @@ function CreateFlowLayoutContent({
|
|||||||
: "max-md:flex-col max-md:items-center";
|
: "max-md:flex-col max-md:items-center";
|
||||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||||
const saveDraftOnExit =
|
const saveDraftOnExit =
|
||||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
Boolean(sessionUser) &&
|
||||||
|
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
|
||||||
|
|
||||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -408,7 +451,15 @@ function CreateFlowLayoutContent({
|
|||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onEdit={
|
onEdit={
|
||||||
isCompletedStep
|
isCompletedStep
|
||||||
? () => router.push("/create/final-review")
|
? () => {
|
||||||
|
const last = readLastPublishedRule();
|
||||||
|
if (!last) return;
|
||||||
|
updateState({
|
||||||
|
editingPublishedRuleId: last.id,
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
router.push("/create/edit-rule");
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onExit={(opts) => void handleExit(opts)}
|
onExit={(opts) => void handleExit(opts)}
|
||||||
@@ -425,7 +476,7 @@ function CreateFlowLayoutContent({
|
|||||||
{!isCompletedStep && (
|
{!isCompletedStep && (
|
||||||
<CreateFlowFooter
|
<CreateFlowFooter
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
|
progressBar={!isTemplateReviewRoute && !isFinalReviewLike}
|
||||||
proportionBarProgress={proportionBarProgress}
|
proportionBarProgress={proportionBarProgress}
|
||||||
proportionBarVariant="segmented"
|
proportionBarVariant="segmented"
|
||||||
secondButton={
|
secondButton={
|
||||||
@@ -555,7 +606,7 @@ function CreateFlowLayoutContent({
|
|||||||
>
|
>
|
||||||
{footer[customRuleConfirmFooter.footerMessageKey]}
|
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||||
</Button>
|
</Button>
|
||||||
) : nextStep ? (
|
) : nextStep || isFinalReviewLike ? (
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
@@ -563,14 +614,14 @@ function CreateFlowLayoutContent({
|
|||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentStep === "final-review") {
|
if (isFinalReviewLike) {
|
||||||
void handleFinalize();
|
void handleFinalize();
|
||||||
} else {
|
} else {
|
||||||
goToNextStep();
|
goToNextStep();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentStep === "final-review"
|
{isFinalReviewLike
|
||||||
? isPublishing
|
? isPublishing
|
||||||
? messages.create.reviewAndComplete.publish
|
? messages.create.reviewAndComplete.publish
|
||||||
.finalizeButtonPublishing
|
.finalizeButtonPublishing
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit published rule: community description with the same 200-char limit as
|
||||||
|
* {@link CreateFlowScreenView} `community-context` step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Create from "../../../components/modals/Create";
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
/** Matches `community-context` step and `createFlowSchemas` communityContext.max(200). */
|
||||||
|
export const COMMUNITY_CONTEXT_FIELD_MAX_LENGTH = 200;
|
||||||
|
|
||||||
|
export interface FinalReviewCommunityContextEditModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Current `communityContext` (trimmed for display; draft seeds from raw state in parent). */
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (_value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinalReviewCommunityContextEditModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
}: FinalReviewCommunityContextEditModalProps) {
|
||||||
|
const tModal = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.communityContextEditModal",
|
||||||
|
);
|
||||||
|
const tField = useTranslation("create.community.communityContext");
|
||||||
|
const tSave = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const initialRef = useRef("");
|
||||||
|
const seededOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
seededOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seededOpenRef.current) return;
|
||||||
|
seededOpenRef.current = true;
|
||||||
|
const seed = initialValue;
|
||||||
|
setDraft(seed);
|
||||||
|
initialRef.current = seed;
|
||||||
|
}, [isOpen, initialValue]);
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => draft !== initialRef.current,
|
||||||
|
[draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const characterHint = tField("characterCountTemplate")
|
||||||
|
.replace("{current}", String(draft.length))
|
||||||
|
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
const trimmed = draft.trimEnd();
|
||||||
|
const capped = trimmed.slice(0, COMMUNITY_CONTEXT_FIELD_MAX_LENGTH);
|
||||||
|
onSave(capped);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={tModal("title")}
|
||||||
|
description={tModal("description")}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
nextButtonText={tSave("saveButton")}
|
||||||
|
nextButtonDisabled={!isDirty}
|
||||||
|
onNext={handleSave}
|
||||||
|
ariaLabel={tModal("title")}
|
||||||
|
>
|
||||||
|
<div className="pb-2">
|
||||||
|
<TextInput
|
||||||
|
className="!transition-none"
|
||||||
|
type="text"
|
||||||
|
placeholder={tField("placeholder")}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
inputSize="medium"
|
||||||
|
formHeader={false}
|
||||||
|
textHint={characterHint}
|
||||||
|
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||||
import { saveDraftToServer } from "../../../../lib/create/api";
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
|
import {
|
||||||
|
deleteServerDraft,
|
||||||
|
saveDraftToServer,
|
||||||
|
updatePublishedRule,
|
||||||
|
} from "../../../../lib/create/api";
|
||||||
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
@@ -44,16 +50,52 @@ export function useCreateFlowExit({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (saveDraft && SYNC_ENABLED) {
|
if (saveDraft && SYNC_ENABLED) {
|
||||||
const payload: CreateFlowState = {
|
const editingId =
|
||||||
...state,
|
typeof state.editingPublishedRuleId === "string"
|
||||||
...(currentStep ? { currentStep } : {}),
|
? state.editingPublishedRuleId.trim()
|
||||||
};
|
: "";
|
||||||
const result = await saveDraftToServer(payload);
|
if (editingId.length > 0) {
|
||||||
if (result.ok === true) {
|
const payloadResult = buildPublishPayload(state);
|
||||||
setDraftSaveBannerMessage?.(null);
|
if (payloadResult.ok === false) {
|
||||||
|
setDraftSaveBannerMessage?.(
|
||||||
|
payloadResult.error === "missingCommunityName"
|
||||||
|
? messages.create.reviewAndComplete.publish
|
||||||
|
.missingCommunityName
|
||||||
|
: payloadResult.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { title, summary, document } = payloadResult;
|
||||||
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
if (updateResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
setDraftSaveBannerMessage?.(null);
|
||||||
|
void deleteServerDraft();
|
||||||
|
} else {
|
||||||
|
setDraftSaveBannerMessage?.(updateResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setDraftSaveBannerMessage?.(result.message);
|
const payload: CreateFlowState = {
|
||||||
return;
|
...state,
|
||||||
|
...(currentStep ? { currentStep } : {}),
|
||||||
|
};
|
||||||
|
const result = await saveDraftToServer(payload);
|
||||||
|
if (result.ok === true) {
|
||||||
|
setDraftSaveBannerMessage?.(null);
|
||||||
|
} else {
|
||||||
|
setDraftSaveBannerMessage?.(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
import { publishRule } from "../../../../lib/create/api";
|
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
import type { CreateFlowState } from "../types";
|
import type { CreateFlowState } from "../types";
|
||||||
@@ -23,12 +23,8 @@ export type UseCreateFlowFinalizeResult = {
|
|||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
/**
|
/**
|
||||||
* Build a publish payload from the current `CreateFlowState`, post it to
|
* Build a publish payload from the current `CreateFlowState`, post it to
|
||||||
* `publishRule`, and route to `/create/completed` on success.
|
* `publishRule` (or PATCH when editing a published rule), and route to
|
||||||
*
|
* `/create/completed` on success.
|
||||||
* Failure modes:
|
|
||||||
* - Payload validation fails → surface the localized banner message.
|
|
||||||
* - 401 from the API → re-open the login modal targeting `/create/final-review?syncDraft=1` so the user can retry post-auth.
|
|
||||||
* - Any other failure → show either the trimmed server message or a generic localized fallback.
|
|
||||||
*/
|
*/
|
||||||
finalize: () => Promise<void>;
|
finalize: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -43,10 +39,15 @@ export function useCreateFlowFinalize({
|
|||||||
state,
|
state,
|
||||||
router,
|
router,
|
||||||
openLogin,
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
}: {
|
}: {
|
||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
router: AppRouterLike;
|
router: AppRouterLike;
|
||||||
openLogin: OpenLogin;
|
openLogin: OpenLogin;
|
||||||
|
updateState: (_patch: Partial<CreateFlowState>) => void;
|
||||||
|
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
||||||
|
loginReturnPath: string;
|
||||||
}): UseCreateFlowFinalizeResult {
|
}): UseCreateFlowFinalizeResult {
|
||||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -66,6 +67,46 @@ export function useCreateFlowFinalize({
|
|||||||
}
|
}
|
||||||
const { title, summary, document: ruleDocument } = payloadResult;
|
const { title, summary, document: ruleDocument } = payloadResult;
|
||||||
setIsPublishing(true);
|
setIsPublishing(true);
|
||||||
|
|
||||||
|
const editingId =
|
||||||
|
typeof state.editingPublishedRuleId === "string"
|
||||||
|
? state.editingPublishedRuleId.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (editingId.length > 0) {
|
||||||
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
setIsPublishing(false);
|
||||||
|
if (updateResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
updateState({ editingPublishedRuleId: undefined });
|
||||||
|
router.push("/create/completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updateResult.status === 401) {
|
||||||
|
openLogin({
|
||||||
|
variant: "default",
|
||||||
|
nextPath: loginReturnPath,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPublishBannerMessage(
|
||||||
|
updateResult.error.trim() !== ""
|
||||||
|
? updateResult.error
|
||||||
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const publishResult = await publishRule({
|
const publishResult = await publishRule({
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
@@ -85,7 +126,7 @@ export function useCreateFlowFinalize({
|
|||||||
if (publishResult.status === 401) {
|
if (publishResult.status === 401) {
|
||||||
openLogin({
|
openLogin({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
nextPath: "/create/final-review?syncDraft=1",
|
nextPath: loginReturnPath,
|
||||||
backdropVariant: "blurredYellow",
|
backdropVariant: "blurredYellow",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -95,7 +136,7 @@ export function useCreateFlowFinalize({
|
|||||||
? publishResult.error
|
? publishResult.error
|
||||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
);
|
);
|
||||||
}, [state, router, openLogin]);
|
}, [state, router, openLogin, updateState, loginReturnPath]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publishBannerMessage,
|
publishBannerMessage,
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export function CreateFlowScreenView({
|
|||||||
return <ConfirmStakeholdersScreen />;
|
return <ConfirmStakeholdersScreen />;
|
||||||
case "final-review":
|
case "final-review":
|
||||||
return <FinalReviewScreen />;
|
return <FinalReviewScreen />;
|
||||||
|
case "edit-rule":
|
||||||
|
return <FinalReviewScreen variant="editPublished" />;
|
||||||
case "completed":
|
case "completed":
|
||||||
return <CompletedScreen />;
|
return <CompletedScreen />;
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type FinalReviewChipEditPatch,
|
type FinalReviewChipEditPatch,
|
||||||
type FinalReviewChipEditTarget,
|
type FinalReviewChipEditTarget,
|
||||||
} from "../../components/FinalReviewChipEditModal";
|
} from "../../components/FinalReviewChipEditModal";
|
||||||
|
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||||
import {
|
import {
|
||||||
getAssetPath,
|
getAssetPath,
|
||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
@@ -68,7 +69,11 @@ function readFallbackCategoryRows(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FinalReviewScreen() {
|
export function FinalReviewScreen({
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
variant?: "default" | "editPublished";
|
||||||
|
} = {}) {
|
||||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||||
@@ -93,6 +98,8 @@ export function FinalReviewScreen() {
|
|||||||
useState<FinalReviewChipEditTarget | null>(null);
|
useState<FinalReviewChipEditTarget | null>(null);
|
||||||
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
|
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
|
||||||
useState<TemplateChipDetail | null>(null);
|
useState<TemplateChipDetail | null>(null);
|
||||||
|
const [communityContextModalOpen, setCommunityContextModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const handleSave = useCallback(
|
const handleSave = useCallback(
|
||||||
(patch: FinalReviewChipEditPatch) => {
|
(patch: FinalReviewChipEditPatch) => {
|
||||||
@@ -180,14 +187,37 @@ export function FinalReviewScreen() {
|
|||||||
return raw.length > 0 ? raw : undefined;
|
return raw.length > 0 ? raw : undefined;
|
||||||
}, [state.communityContext]);
|
}, [state.communityContext]);
|
||||||
|
|
||||||
|
const rawCommunityContextForModal =
|
||||||
|
typeof state.communityContext === "string" ? state.communityContext : "";
|
||||||
|
|
||||||
|
const descriptionEmptyHint =
|
||||||
|
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowLockupCardStepShell
|
<CreateFlowLockupCardStepShell
|
||||||
lockupTitle={t("title")}
|
lockupTitle={
|
||||||
lockupDescription={t("description")}
|
variant === "editPublished" ? t("editPublishedTitle") : t("title")
|
||||||
|
}
|
||||||
|
lockupDescription={
|
||||||
|
variant === "editPublished"
|
||||||
|
? t("editPublishedDescription")
|
||||||
|
: t("description")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Rule
|
<Rule
|
||||||
title={ruleCardTitle}
|
title={ruleCardTitle}
|
||||||
description={ruleCardDescription}
|
description={ruleCardDescription}
|
||||||
|
onDescriptionClick={
|
||||||
|
variant === "editPublished"
|
||||||
|
? () => setCommunityContextModalOpen(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
descriptionEmptyHint={descriptionEmptyHint}
|
||||||
|
descriptionEditAriaLabel={
|
||||||
|
variant === "editPublished"
|
||||||
|
? t("communityContextEditModal.ariaEditDescription")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
size={mdUp ? "L" : "M"}
|
size={mdUp ? "L" : "M"}
|
||||||
expanded={true}
|
expanded={true}
|
||||||
backgroundColor="bg-[#c9fef9]"
|
backgroundColor="bg-[#c9fef9]"
|
||||||
@@ -209,6 +239,17 @@ export function FinalReviewScreen() {
|
|||||||
onClose={() => setActiveReadOnlyDetail(null)}
|
onClose={() => setActiveReadOnlyDetail(null)}
|
||||||
detail={activeReadOnlyDetail}
|
detail={activeReadOnlyDetail}
|
||||||
/>
|
/>
|
||||||
|
{variant === "editPublished" ? (
|
||||||
|
<FinalReviewCommunityContextEditModal
|
||||||
|
isOpen={communityContextModalOpen}
|
||||||
|
onClose={() => setCommunityContextModalOpen(false)}
|
||||||
|
initialValue={rawCommunityContextForModal}
|
||||||
|
onSave={(value) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({ communityContext: value, summary: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</CreateFlowLockupCardStepShell>
|
</CreateFlowLockupCardStepShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type CreateFlowStep =
|
|||||||
| "conflict-management"
|
| "conflict-management"
|
||||||
| "confirm-stakeholders"
|
| "confirm-stakeholders"
|
||||||
| "final-review"
|
| "final-review"
|
||||||
|
/** Branch-only URL: same UI as final-review; editing an already-published rule from completed. */
|
||||||
|
| "edit-rule"
|
||||||
| "completed";
|
| "completed";
|
||||||
|
|
||||||
/** String keys used by generic text-field steps for `CreateFlowState`. */
|
/** String keys used by generic text-field steps for `CreateFlowState`. */
|
||||||
@@ -173,6 +175,11 @@ export interface CreateFlowState {
|
|||||||
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||||
*/
|
*/
|
||||||
templateReviewEntryFromCreateFlow?: boolean;
|
templateReviewEntryFromCreateFlow?: boolean;
|
||||||
|
/**
|
||||||
|
* When set, **Finalize** and signed-in **Save & Exit** update this published
|
||||||
|
* rule (PATCH) instead of POSTing a new rule or only saving a draft.
|
||||||
|
*/
|
||||||
|
editingPublishedRuleId?: string;
|
||||||
currentStep?: CreateFlowStep;
|
currentStep?: CreateFlowStep;
|
||||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||||
sections?: Record<string, unknown>[];
|
sections?: Record<string, unknown>[];
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ if (PROPORTION_BY_STEP_INDEX.length !== FLOW_STEP_ORDER.length) {
|
|||||||
export function getProportionBarProgressForCreateFlowStep(
|
export function getProportionBarProgressForCreateFlowStep(
|
||||||
step: CreateFlowStep | null | undefined,
|
step: CreateFlowStep | null | undefined,
|
||||||
): ProportionBarState {
|
): ProportionBarState {
|
||||||
|
if (step === "edit-rule") {
|
||||||
|
return "3-2";
|
||||||
|
}
|
||||||
const idx = getStepIndex(step);
|
const idx = getStepIndex(step);
|
||||||
if (idx < 0) return "1-0";
|
if (idx < 0) return "1-0";
|
||||||
return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0";
|
return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0";
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
|||||||
messageNamespace: "create.reviewAndComplete.finalReview",
|
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
|
"edit-rule": {
|
||||||
|
layoutKind: "review",
|
||||||
|
figmaNodeId: "20907-212767",
|
||||||
|
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||||
|
centeredBodyBelowMd: false,
|
||||||
|
},
|
||||||
completed: {
|
completed: {
|
||||||
layoutKind: "completed",
|
layoutKind: "completed",
|
||||||
figmaNodeId: "20907-213286",
|
figmaNodeId: "20907-213286",
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid step IDs for the create flow (for validation)
|
* Valid URL segments for `/create/[screenId]` (includes branch-only `edit-rule`).
|
||||||
|
* Linear order for navigation remains {@link FLOW_STEP_ORDER}.
|
||||||
*/
|
*/
|
||||||
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
export const VALID_STEPS: readonly CreateFlowStep[] = [
|
||||||
|
...FLOW_STEP_ORDER,
|
||||||
|
"edit-rule",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First step in the flow (entry point)
|
* First step in the flow (entry point)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/server/db";
|
import { prisma } from "../../../../lib/server/db";
|
||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,9 @@ import {
|
|||||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||||
import { getSessionUser } from "../../../../lib/server/session";
|
import { getSessionUser } from "../../../../lib/server/session";
|
||||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||||
|
import { publishRuleBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
||||||
|
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -41,6 +45,56 @@ export const GET = apiRoute<RouteContext>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PATCH = apiRoute<RouteContext>(
|
||||||
|
"rules.byId.patch",
|
||||||
|
async (request: NextRequest, context) => {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
const row = await prisma.publishedRule.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
if (row.userId !== user.id) {
|
||||||
|
return forbidden("You do not have permission to update this rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = await readLimitedJson(request);
|
||||||
|
if (parsedBody.ok === false) {
|
||||||
|
return parsedBody.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||||
|
if (!validated.success) {
|
||||||
|
return jsonFromZodError(validated.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, summary, document } = validated.data;
|
||||||
|
|
||||||
|
await prisma.publishedRule.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document: document as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const DELETE = apiRoute<RouteContext>(
|
export const DELETE = apiRoute<RouteContext>(
|
||||||
"rules.byId.delete",
|
"rules.byId.delete",
|
||||||
async (_request, context) => {
|
async (_request, context) => {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface InlineTextButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
|
/** When set, removes the default underline (e.g. inverse surfaces). */
|
||||||
|
underline?: boolean;
|
||||||
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +40,16 @@ function InlineTextButtonComponent({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
type = "button",
|
type = "button",
|
||||||
|
underline = true,
|
||||||
|
"data-testid": dataTestId,
|
||||||
}: InlineTextButtonProps) {
|
}: InlineTextButtonProps) {
|
||||||
const baseClasses =
|
const baseClasses = [
|
||||||
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
"cursor-pointer border-none bg-transparent p-0",
|
||||||
|
underline
|
||||||
|
? "font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px]"
|
||||||
|
: "text-[length:inherit] leading-[inherit] text-[color:inherit] no-underline",
|
||||||
|
"hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -47,6 +57,7 @@ function InlineTextButtonComponent({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
data-testid={dataTestId}
|
||||||
className={`${baseClasses} ${className}`.trim()}
|
className={`${baseClasses} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
({
|
({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
onDescriptionClick,
|
||||||
|
descriptionEmptyHint,
|
||||||
|
descriptionEditAriaLabel,
|
||||||
icon,
|
icon,
|
||||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||||
className = "",
|
className = "",
|
||||||
@@ -75,6 +78,9 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
<RuleView
|
<RuleView
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
|
onDescriptionClick={onDescriptionClick}
|
||||||
|
descriptionEmptyHint={descriptionEmptyHint}
|
||||||
|
descriptionEditAriaLabel={descriptionEditAriaLabel}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ export interface RuleBottomLink {
|
|||||||
export interface RuleProps {
|
export interface RuleProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/**
|
||||||
|
* When set, the description row (or {@link descriptionEmptyHint} when there
|
||||||
|
* is no body text) is clickable — caller handles modal / navigation.
|
||||||
|
*/
|
||||||
|
onDescriptionClick?: () => void;
|
||||||
|
/**
|
||||||
|
* When {@link onDescriptionClick} is set, forwarded to the control’s
|
||||||
|
* `aria-label` (keyboard / SR).
|
||||||
|
*/
|
||||||
|
descriptionEditAriaLabel?: string;
|
||||||
|
/** Shown when {@link onDescriptionClick} is set and `description` is empty. */
|
||||||
|
descriptionEmptyHint?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -51,6 +63,9 @@ export interface RuleProps {
|
|||||||
export interface RuleViewProps {
|
export interface RuleViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
onDescriptionClick?: () => void;
|
||||||
|
descriptionEmptyHint?: string;
|
||||||
|
descriptionEditAriaLabel?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
className: string;
|
className: string;
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import MultiSelect from "../../controls/MultiSelect";
|
import MultiSelect from "../../controls/MultiSelect";
|
||||||
|
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||||
import NavigationLink from "../../navigation/Link";
|
import NavigationLink from "../../navigation/Link";
|
||||||
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
||||||
|
|
||||||
export function RuleView({
|
export function RuleView({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
onDescriptionClick,
|
||||||
|
descriptionEmptyHint,
|
||||||
|
descriptionEditAriaLabel,
|
||||||
icon,
|
icon,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
className,
|
className,
|
||||||
@@ -314,6 +318,41 @@ export function RuleView({
|
|||||||
</div>
|
</div>
|
||||||
) : expanded ? (
|
) : expanded ? (
|
||||||
<>
|
<>
|
||||||
|
{(description ||
|
||||||
|
(onDescriptionClick &&
|
||||||
|
typeof descriptionEmptyHint === "string")) && (
|
||||||
|
<div
|
||||||
|
className={`relative w-full shrink-0 border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px] ${
|
||||||
|
expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{onDescriptionClick ? (
|
||||||
|
<InlineTextButton
|
||||||
|
type="button"
|
||||||
|
underline={false}
|
||||||
|
data-testid="rule-description-edit"
|
||||||
|
ariaLabel={descriptionEditAriaLabel}
|
||||||
|
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
|
||||||
|
!description && descriptionEmptyHint ? "opacity-70" : ""
|
||||||
|
}`.trim()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDescriptionClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description ?? descriptionEmptyHint ?? ""}
|
||||||
|
</InlineTextButton>
|
||||||
|
) : (
|
||||||
|
description && (
|
||||||
|
<p
|
||||||
|
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Categories Section - Using MultiSelect */}
|
{/* Categories Section - Using MultiSelect */}
|
||||||
{categories && categories.length > 0 && (
|
{categories && categories.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -352,16 +391,6 @@ export function RuleView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Footer: Description */}
|
|
||||||
{description && (
|
|
||||||
<div className="border-t border-solid border-[var(--color-content-invert-primary)] pt-[16px] relative shrink-0 w-full">
|
|
||||||
<p
|
|
||||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Collapsed State: Description */
|
/* Collapsed State: Description */
|
||||||
|
|||||||
@@ -241,6 +241,48 @@ export async function publishRule(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePublishedRule(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string; status?: number }> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary ?? null,
|
||||||
|
document: input.document,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await safeParseJsonResponse(res);
|
||||||
|
if (!res.ok) {
|
||||||
|
const fromBody =
|
||||||
|
data && typeof data === "object" ? readApiErrorMessage(data) : null;
|
||||||
|
const msg =
|
||||||
|
fromBody && fromBody !== "Request failed"
|
||||||
|
? fromBody
|
||||||
|
: res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: msg,
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true as const };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: DRAFT_SAVE_NETWORK_ERROR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type MyPublishedRule = {
|
export type MyPublishedRule = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||||
|
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||||
|
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehydrate create-flow fields from a stored published rule so `/create/edit-rule`
|
||||||
|
* can render final-review editors after refresh or when branching from completed.
|
||||||
|
*/
|
||||||
|
export function createFlowStateFromPublishedRule(
|
||||||
|
rule: StoredLastPublishedRule,
|
||||||
|
): Partial<CreateFlowState> {
|
||||||
|
const doc = rule.document;
|
||||||
|
const out: Partial<CreateFlowState> = {
|
||||||
|
title: rule.title,
|
||||||
|
editingPublishedRuleId: rule.id,
|
||||||
|
};
|
||||||
|
const sum = typeof rule.summary === "string" ? rule.summary.trim() : "";
|
||||||
|
if (sum.length > 0) {
|
||||||
|
out.communityContext = sum;
|
||||||
|
out.summary = sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreValues = doc.coreValues;
|
||||||
|
if (Array.isArray(coreValues) && coreValues.length > 0) {
|
||||||
|
const selectedCoreValueIds: string[] = [];
|
||||||
|
const coreValuesChipsSnapshot: NonNullable<
|
||||||
|
CreateFlowState["coreValuesChipsSnapshot"]
|
||||||
|
> = [];
|
||||||
|
const coreValueDetailsByChipId: NonNullable<
|
||||||
|
CreateFlowState["coreValueDetailsByChipId"]
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const row of coreValues) {
|
||||||
|
if (!row || typeof row !== "object") continue;
|
||||||
|
const o = row as Record<string, unknown>;
|
||||||
|
const chipIdRaw = typeof o.chipId === "string" ? o.chipId.trim() : "";
|
||||||
|
const label = typeof o.label === "string" ? o.label.trim() : "";
|
||||||
|
if (!label) continue;
|
||||||
|
const chipId =
|
||||||
|
chipIdRaw.length > 0 ? chipIdRaw : `hydrated-${label.toLowerCase()}`;
|
||||||
|
selectedCoreValueIds.push(chipId);
|
||||||
|
coreValuesChipsSnapshot.push({
|
||||||
|
id: chipId,
|
||||||
|
label,
|
||||||
|
state: "selected",
|
||||||
|
});
|
||||||
|
coreValueDetailsByChipId[chipId] = {
|
||||||
|
meaning: typeof o.meaning === "string" ? o.meaning : "",
|
||||||
|
signals: typeof o.signals === "string" ? o.signals : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
out.selectedCoreValueIds = selectedCoreValueIds;
|
||||||
|
out.coreValuesChipsSnapshot = coreValuesChipsSnapshot;
|
||||||
|
out.coreValueDetailsByChipId = coreValueDetailsByChipId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msRaw = doc.methodSelections;
|
||||||
|
if (!msRaw || typeof msRaw !== "object" || Array.isArray(msRaw)) {
|
||||||
|
out.sections = [];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const ms = msRaw as PublishedMethodSelections;
|
||||||
|
|
||||||
|
if (Array.isArray(ms.communication) && ms.communication.length > 0) {
|
||||||
|
out.selectedCommunicationMethodIds = ms.communication.map((x) => x.id);
|
||||||
|
out.communicationMethodDetailsById = Object.fromEntries(
|
||||||
|
ms.communication.map((x) => [x.id, x.sections]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(ms.membership) && ms.membership.length > 0) {
|
||||||
|
out.selectedMembershipMethodIds = ms.membership.map((x) => x.id);
|
||||||
|
out.membershipMethodDetailsById = Object.fromEntries(
|
||||||
|
ms.membership.map((x) => [x.id, x.sections]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Array.isArray(ms.decisionApproaches) &&
|
||||||
|
ms.decisionApproaches.length > 0
|
||||||
|
) {
|
||||||
|
out.selectedDecisionApproachIds = ms.decisionApproaches.map((x) => x.id);
|
||||||
|
out.decisionApproachDetailsById = Object.fromEntries(
|
||||||
|
ms.decisionApproaches.map((x) => [x.id, x.sections]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Array.isArray(ms.conflictManagement) &&
|
||||||
|
ms.conflictManagement.length > 0
|
||||||
|
) {
|
||||||
|
out.selectedConflictManagementIds = ms.conflictManagement.map(
|
||||||
|
(x) => x.id,
|
||||||
|
);
|
||||||
|
out.conflictManagementDetailsById = Object.fromEntries(
|
||||||
|
ms.conflictManagement.map((x) => [x.id, x.sections]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop template `sections` so final-review uses `methodSelections` / selected ids (edit path). */
|
||||||
|
out.sections = [];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -121,6 +121,7 @@ export const createFlowStateSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
templateReviewBackSlug: z.string().max(200).optional(),
|
templateReviewBackSlug: z.string().max(200).optional(),
|
||||||
templateReviewEntryFromCreateFlow: z.boolean().optional(),
|
templateReviewEntryFromCreateFlow: z.boolean().optional(),
|
||||||
|
editingPublishedRuleId: z.string().max(200).optional(),
|
||||||
currentStep: createFlowStepSchema.optional(),
|
currentStep: createFlowStepSchema.optional(),
|
||||||
sections: z.array(z.unknown()).optional(),
|
sections: z.array(z.unknown()).optional(),
|
||||||
stakeholders: z.array(z.unknown()).optional(),
|
stakeholders: z.array(z.unknown()).optional(),
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
{
|
{
|
||||||
"title": "Review your CommunityRule",
|
"title": "Review your CommunityRule",
|
||||||
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
|
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
|
||||||
|
"editPublishedTitle": "Edit your CommunityRule",
|
||||||
|
"editPublishedDescription": "Update what others see on your public rule. Save & Exit or Finalize applies changes to your published CommunityRule.",
|
||||||
"ruleCardTitleFallback": "Your community",
|
"ruleCardTitleFallback": "Your community",
|
||||||
"chipEditModal": {
|
"chipEditModal": {
|
||||||
"saveButton": "Save",
|
"saveButton": "Save",
|
||||||
"readOnlyCloseButton": "Close",
|
"readOnlyCloseButton": "Close",
|
||||||
"readOnlyNote": "Details for this entry aren't editable yet."
|
"readOnlyNote": "Details for this entry aren't editable yet."
|
||||||
},
|
},
|
||||||
|
"communityContextEditModal": {
|
||||||
|
"title": "Community description",
|
||||||
|
"description": "Update how your organization is described on your public CommunityRule.",
|
||||||
|
"emptyHint": "Add a community description",
|
||||||
|
"ariaEditDescription": "Edit community description"
|
||||||
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
{
|
{
|
||||||
"name": "Values",
|
"name": "Values",
|
||||||
|
|||||||
@@ -401,3 +401,67 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
|||||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function FinalReviewEditPublishedWithStateProbe({
|
||||||
|
onState,
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
onState: (_state: CreateFlowState) => void;
|
||||||
|
initial: CreateFlowState;
|
||||||
|
}) {
|
||||||
|
const { state, replaceState } = useCreateFlow();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
replaceState(initial);
|
||||||
|
}, [replaceState, initial]);
|
||||||
|
useEffect(() => {
|
||||||
|
onState(state);
|
||||||
|
}, [state, onState]);
|
||||||
|
return <FinalReviewScreen variant="editPublished" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FinalReviewScreen — edit published description", () => {
|
||||||
|
it("does not expose click-to-edit description on default final review", () => {
|
||||||
|
render(
|
||||||
|
<FinalReviewWithFlowState
|
||||||
|
title="Oak"
|
||||||
|
communityContext="Visible body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens Save modal from description click and updates communityContext + summary", async () => {
|
||||||
|
let latest: CreateFlowState = {};
|
||||||
|
render(
|
||||||
|
<FinalReviewEditPublishedWithStateProbe
|
||||||
|
onState={(s) => {
|
||||||
|
latest = s;
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
communityContext: "Original",
|
||||||
|
summary: "Original",
|
||||||
|
selectedCommunicationMethodIds: ["signal"],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
void latest;
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId("rule-description-edit"));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText(/Community description/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const input = within(dialog).getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: "Updated copy" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(latest.communityContext).toBe("Updated copy");
|
||||||
|
expect(latest.summary).toBe("Updated copy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -106,6 +106,23 @@ describe("Rule Component", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clicking editable description calls onDescriptionClick and does not fire card onClick", () => {
|
||||||
|
const onCard = vi.fn();
|
||||||
|
const onDesc = vi.fn();
|
||||||
|
render(
|
||||||
|
<Rule
|
||||||
|
{...defaultProps}
|
||||||
|
expanded={true}
|
||||||
|
onClick={onCard}
|
||||||
|
onDescriptionClick={onDesc}
|
||||||
|
descriptionEmptyHint="Add description"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("rule-description-edit"));
|
||||||
|
expect(onDesc).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCard).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies proper sizing for expanded states", () => {
|
it("applies proper sizing for expanded states", () => {
|
||||||
render(<Rule {...defaultProps} expanded={true} size="L" />);
|
render(<Rule {...defaultProps} expanded={true} size="L" />);
|
||||||
|
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
|
|||||||
getProportionBarProgressForCreateFlowStep("conflict-management"),
|
getProportionBarProgressForCreateFlowStep("conflict-management"),
|
||||||
).toBe("3-0");
|
).toBe("3-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses 3-2 on edit-rule (same as final-review segment)", () => {
|
||||||
|
expect(getProportionBarProgressForCreateFlowStep("edit-rule")).toBe("3-2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ describe("flowSteps", () => {
|
|||||||
expect(getPreviousStep(null)).toBeNull();
|
expect(getPreviousStep(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isValidStep reflects FLOW_STEP_ORDER membership", () => {
|
it("isValidStep allows branch-only edit-rule URL segment", () => {
|
||||||
expect(isValidStep("community-size")).toBe(true);
|
expect(isValidStep("edit-rule")).toBe(true);
|
||||||
expect(isValidStep("confirm-stakeholders")).toBe(true);
|
});
|
||||||
expect(isValidStep("core-values")).toBe(true);
|
|
||||||
expect(isValidStep("nope")).toBe(false);
|
it("getNextStep and getPreviousStep return null for edit-rule (not in linear order)", () => {
|
||||||
expect(isValidStep(null)).toBe(false);
|
expect(getNextStep("edit-rule")).toBeNull();
|
||||||
|
expect(getPreviousStep("edit-rule")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getStepIndex matches position in FLOW_STEP_ORDER", () => {
|
it("getStepIndex matches position in FLOW_STEP_ORDER", () => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createFlowStateFromPublishedRule } from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
|
|
||||||
|
describe("createFlowStateFromPublishedRule", () => {
|
||||||
|
it("maps coreValues and methodSelections into draft fields", () => {
|
||||||
|
const partial = createFlowStateFromPublishedRule({
|
||||||
|
id: "rule-1",
|
||||||
|
title: "Oak",
|
||||||
|
summary: "River cleanup",
|
||||||
|
document: {
|
||||||
|
coreValues: [
|
||||||
|
{ chipId: "1", label: "Ecology", meaning: "m", signals: "s" },
|
||||||
|
],
|
||||||
|
methodSelections: {
|
||||||
|
communication: [
|
||||||
|
{
|
||||||
|
id: "slack",
|
||||||
|
label: "Slack",
|
||||||
|
sections: {
|
||||||
|
corePrinciple: "p",
|
||||||
|
logisticsAdmin: "l",
|
||||||
|
codeOfConduct: "c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(partial.editingPublishedRuleId).toBe("rule-1");
|
||||||
|
expect(partial.title).toBe("Oak");
|
||||||
|
expect(partial.communityContext).toBe("River cleanup");
|
||||||
|
expect(partial.selectedCoreValueIds).toEqual(["1"]);
|
||||||
|
expect(partial.coreValuesChipsSnapshot?.[0]?.label).toBe("Ecology");
|
||||||
|
expect(partial.selectedCommunicationMethodIds).toEqual(["slack"]);
|
||||||
|
expect(partial.communicationMethodDetailsById?.slack?.corePrinciple).toBe(
|
||||||
|
"p",
|
||||||
|
);
|
||||||
|
expect(partial.sections).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
||||||
|
const partial = createFlowStateFromPublishedRule({
|
||||||
|
id: "rule-2",
|
||||||
|
title: "Pine",
|
||||||
|
summary: "",
|
||||||
|
document: {
|
||||||
|
coreValues: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(partial.sections).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const isDatabaseConfiguredMock = vi.fn();
|
||||||
|
const findUniqueMock = vi.fn();
|
||||||
|
const updateMock = vi.fn();
|
||||||
|
const getSessionUserMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/env", () => ({
|
||||||
|
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/db", () => ({
|
||||||
|
prisma: {
|
||||||
|
publishedRule: {
|
||||||
|
findUnique: (...args: unknown[]) => findUniqueMock(...args),
|
||||||
|
update: (...args: unknown[]) => updateMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/session", () => ({
|
||||||
|
getSessionUser: () => getSessionUserMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PATCH } from "../../app/api/rules/[id]/route";
|
||||||
|
|
||||||
|
function makeContext(id: string) {
|
||||||
|
return { params: Promise.resolve({ id }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
isDatabaseConfiguredMock.mockReset();
|
||||||
|
findUniqueMock.mockReset();
|
||||||
|
updateMock.mockReset();
|
||||||
|
getSessionUserMock.mockReset();
|
||||||
|
getSessionUserMock.mockResolvedValue({ id: "user-1", email: "a@b.c" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/rules/[id]", () => {
|
||||||
|
it("returns 401 when unauthenticated", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
getSessionUserMock.mockResolvedValueOnce(null);
|
||||||
|
const res = await PATCH(
|
||||||
|
new NextRequest("https://x.test/api/rules/r1", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "T",
|
||||||
|
summary: null,
|
||||||
|
document: { sections: [] },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
makeContext("r1"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when the rule belongs to another user", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
findUniqueMock.mockResolvedValueOnce({
|
||||||
|
id: "r1",
|
||||||
|
userId: "other",
|
||||||
|
});
|
||||||
|
const res = await PATCH(
|
||||||
|
new NextRequest("https://x.test/api/rules/r1", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "T",
|
||||||
|
summary: null,
|
||||||
|
document: { sections: [] },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
makeContext("r1"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the rule when owner matches", async () => {
|
||||||
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
|
findUniqueMock.mockResolvedValueOnce({
|
||||||
|
id: "r1",
|
||||||
|
userId: "user-1",
|
||||||
|
});
|
||||||
|
updateMock.mockResolvedValueOnce({});
|
||||||
|
const res = await PATCH(
|
||||||
|
new NextRequest("https://x.test/api/rules/r1", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Updated",
|
||||||
|
summary: "Context",
|
||||||
|
document: { sections: [], coreValues: [] },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
makeContext("r1"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(updateMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: "r1" },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
title: "Updated",
|
||||||
|
summary: "Context",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user