Template navigation and review/complete cleanup
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { publishRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import type { CreateFlowState } from "../types";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
|
||||
type OpenLogin = (args: {
|
||||
variant: "default" | "saveProgress";
|
||||
nextPath: string;
|
||||
backdropVariant: "blurredYellow";
|
||||
}) => void;
|
||||
|
||||
export type UseCreateFlowFinalizeResult = {
|
||||
/** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */
|
||||
publishBannerMessage: string | null;
|
||||
setPublishBannerMessage: (_message: string | null) => void;
|
||||
/** True from the moment the publish request fires until the response resolves. */
|
||||
isPublishing: boolean;
|
||||
/**
|
||||
* Build a publish payload from the current `CreateFlowState`, post it to
|
||||
* `publishRule`, 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>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encapsulates the Final Review → publish flow that previously lived inline
|
||||
* in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag)
|
||||
* co-located with the publish handler so the layout shell only has to wire
|
||||
* the resulting message into its banner stack.
|
||||
*/
|
||||
export function useCreateFlowFinalize({
|
||||
state,
|
||||
router,
|
||||
openLogin,
|
||||
}: {
|
||||
state: CreateFlowState;
|
||||
router: AppRouterLike;
|
||||
openLogin: OpenLogin;
|
||||
}): UseCreateFlowFinalizeResult {
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const finalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
if (payloadResult.ok === false) {
|
||||
setPublishBannerMessage(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.reviewAndComplete.publish.missingCommunityName
|
||||
: payloadResult.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { title, summary, document: ruleDocument } = payloadResult;
|
||||
setIsPublishing(true);
|
||||
const publishResult = await publishRule({
|
||||
title,
|
||||
summary,
|
||||
document: ruleDocument,
|
||||
});
|
||||
setIsPublishing(false);
|
||||
if (publishResult.ok === true) {
|
||||
writeLastPublishedRule({
|
||||
id: publishResult.id,
|
||||
title,
|
||||
summary: summary ?? null,
|
||||
document: ruleDocument,
|
||||
});
|
||||
router.push("/create/completed");
|
||||
return;
|
||||
}
|
||||
if (publishResult.status === 401) {
|
||||
openLogin({
|
||||
variant: "default",
|
||||
nextPath: "/create/final-review?syncDraft=1",
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPublishBannerMessage(
|
||||
publishResult.error.trim() !== ""
|
||||
? publishResult.error
|
||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||
);
|
||||
}, [state, router, openLogin]);
|
||||
|
||||
return {
|
||||
publishBannerMessage,
|
||||
setPublishBannerMessage,
|
||||
isPublishing,
|
||||
finalize,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useLayoutEffect, useMemo } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import {
|
||||
type CreateFlowNavigationOptions,
|
||||
buildTemplateReviewHref,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
resolveCreateFlowBackTarget,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "../utils/flowSteps";
|
||||
|
||||
/**
|
||||
@@ -25,7 +30,15 @@ const blurActiveElement = (): void => {
|
||||
/**
|
||||
* Hook for Create Rule Flow navigation.
|
||||
*
|
||||
* Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps).
|
||||
* Resolves the active step from `/create/{screenId}` via
|
||||
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
|
||||
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
|
||||
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
|
||||
* from `confirm-stakeholders` instead of `conflict-management`.
|
||||
*
|
||||
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
||||
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
||||
* users who came from `/create/review` return there instead of `/`.
|
||||
*/
|
||||
export function useCreateFlowNavigation(
|
||||
options?: CreateFlowNavigationOptions,
|
||||
@@ -38,15 +51,46 @@ export function useCreateFlowNavigation(
|
||||
canGoBack: () => boolean;
|
||||
nextStep: CreateFlowStep | null;
|
||||
previousStep: CreateFlowStep | null;
|
||||
/** On `/create/review-template/…`, footer Back should go to `/create/review`. */
|
||||
templateReviewFooterBackToCreateReview: boolean;
|
||||
} {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { state, updateState } = useCreateFlow();
|
||||
|
||||
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pathname?.includes("/create/review-template/")) return;
|
||||
if (
|
||||
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (state.templateReviewEntryFromCreateFlow === true) return;
|
||||
updateState({ templateReviewEntryFromCreateFlow: true });
|
||||
}, [
|
||||
pathname,
|
||||
searchParams,
|
||||
state.templateReviewEntryFromCreateFlow,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const nextStep = getNextStep(validStep, options);
|
||||
const previousStep = getPreviousStep(validStep, options);
|
||||
|
||||
const backTarget = useMemo(
|
||||
() =>
|
||||
resolveCreateFlowBackTarget(
|
||||
validStep,
|
||||
options,
|
||||
state.templateReviewBackSlug,
|
||||
),
|
||||
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
|
||||
);
|
||||
|
||||
const goToNextStep = useCallback(() => {
|
||||
blurActiveElement();
|
||||
if (nextStep) {
|
||||
@@ -56,10 +100,26 @@ export function useCreateFlowNavigation(
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
blurActiveElement();
|
||||
if (previousStep) {
|
||||
router.push(`/create/${previousStep}`);
|
||||
if (!backTarget) return;
|
||||
if (backTarget.kind === "templateReview") {
|
||||
router.push(
|
||||
buildTemplateReviewHref(backTarget.slug, {
|
||||
fromCreateWizard: state.templateReviewEntryFromCreateFlow === true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}, [router, previousStep]);
|
||||
router.push(`/create/${backTarget.step}`);
|
||||
}, [router, backTarget, state.templateReviewEntryFromCreateFlow]);
|
||||
|
||||
const templateReviewFooterBackToCreateReview = useMemo(
|
||||
() =>
|
||||
Boolean(state.templateReviewEntryFromCreateFlow) ||
|
||||
(pathname?.includes("/create/review-template/") &&
|
||||
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE),
|
||||
[state.templateReviewEntryFromCreateFlow, pathname, searchParams],
|
||||
);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: CreateFlowStep) => {
|
||||
@@ -70,7 +130,7 @@ export function useCreateFlowNavigation(
|
||||
);
|
||||
|
||||
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
|
||||
const canGoBack = useCallback(() => backTarget != null, [backTarget]);
|
||||
|
||||
return {
|
||||
currentStep: validStep,
|
||||
@@ -81,5 +141,6 @@ export function useCreateFlowNavigation(
|
||||
canGoBack,
|
||||
nextStep,
|
||||
previousStep,
|
||||
templateReviewFooterBackToCreateReview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../../../lib/create/applyTemplatePrefill";
|
||||
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import type { CreateFlowState } from "../types";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
type UpdateState = (_patch: Partial<CreateFlowState>) => void;
|
||||
|
||||
export type UseTemplateReviewActionsResult = {
|
||||
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */
|
||||
isTemplateReviewRoute: boolean;
|
||||
/** Decoded slug parsed out of the template-review pathname, or null. */
|
||||
templateReviewSlug: string | null;
|
||||
/** True between the fetch start and resolution for either action. */
|
||||
isApplyingTemplate: boolean;
|
||||
/** Set when the template fetch failed or the body was malformed. Cleared at the start of each action. */
|
||||
templateReviewApplyError: string | null;
|
||||
setTemplateReviewApplyError: (_message: string | null) => void;
|
||||
/**
|
||||
* Customize: apply the template's selections onto state and route to
|
||||
* `/create/core-values` (if community name is set) or `/create/informational`
|
||||
* with a `pendingTemplateAction` pin so `/create/review` can later replace
|
||||
* itself with `/create/core-values`.
|
||||
*/
|
||||
handleCustomize: () => Promise<void>;
|
||||
/**
|
||||
* Use without changes: scrub any prior customize picks, seed the core-values
|
||||
* snapshot from the template's Values section, drop that section from
|
||||
* `state.sections`, and route to `/create/confirm-stakeholders` (or
|
||||
* `/create/informational` with a pin to skip past `/create/review` to
|
||||
* `/create/confirm-stakeholders` later).
|
||||
*/
|
||||
handleUseWithoutChanges: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encapsulates the two template-review footer actions (Customize / Use
|
||||
* without changes) plus the small amount of state they share (in-flight
|
||||
* flag, error banner, parsed slug). Called from `CreateFlowLayoutClient`
|
||||
* once; extracting it here keeps the layout shell focused on rendering
|
||||
* rather than orchestrating template fetch + state seeding.
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* isTemplateReviewRoute,
|
||||
* templateReviewSlug,
|
||||
* isApplyingTemplate,
|
||||
* templateReviewApplyError,
|
||||
* setTemplateReviewApplyError,
|
||||
* handleCustomize,
|
||||
* handleUseWithoutChanges,
|
||||
* } = useTemplateReviewActions({ pathname, state, updateState, resetCustomRuleSelections, router });
|
||||
*/
|
||||
export function useTemplateReviewActions({
|
||||
pathname,
|
||||
state,
|
||||
updateState,
|
||||
resetCustomRuleSelections,
|
||||
router,
|
||||
}: {
|
||||
pathname: string | null | undefined;
|
||||
state: CreateFlowState;
|
||||
updateState: UpdateState;
|
||||
resetCustomRuleSelections: () => void;
|
||||
router: AppRouterLike;
|
||||
}): UseTemplateReviewActionsResult {
|
||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const templateReviewSlug = useMemo(() => {
|
||||
const m = pathname?.match(/\/create\/review-template\/([^/?#]+)/);
|
||||
return m?.[1] ? decodeURIComponent(m[1]) : null;
|
||||
}, [pathname]);
|
||||
|
||||
const isTemplateReviewRoute = Boolean(
|
||||
pathname?.includes("/create/review-template/"),
|
||||
);
|
||||
|
||||
const handleCustomize = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
setTemplateReviewApplyError(null);
|
||||
setIsApplyingTemplate(true);
|
||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||
setIsApplyingTemplate(false);
|
||||
if (loaded.ok === false) {
|
||||
setTemplateReviewApplyError(loaded.message);
|
||||
return;
|
||||
}
|
||||
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
...prefill,
|
||||
templateReviewBackSlug: undefined,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "customize",
|
||||
},
|
||||
}),
|
||||
});
|
||||
router.push(
|
||||
hasCommunityName ? "/create/core-values" : "/create/informational",
|
||||
);
|
||||
}, [router, state.title, templateReviewSlug, updateState]);
|
||||
|
||||
const handleUseWithoutChanges = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
setTemplateReviewApplyError(null);
|
||||
setIsApplyingTemplate(true);
|
||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||
setIsApplyingTemplate(false);
|
||||
if (loaded.ok === false) {
|
||||
setTemplateReviewApplyError(loaded.message);
|
||||
return;
|
||||
}
|
||||
const { template } = loaded;
|
||||
const doc = template.body;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
setTemplateReviewApplyError(
|
||||
messages.create.templateReview.errors.applyFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sectionsRaw = (doc as { sections?: unknown }).sections;
|
||||
const sections = Array.isArray(sectionsRaw)
|
||||
? (sectionsRaw as Record<string, unknown>[])
|
||||
: [];
|
||||
if (sections.length === 0) {
|
||||
setTemplateReviewApplyError(
|
||||
messages.create.templateReview.errors.applyFailed,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Using the template verbatim: scrub any prior customize picks so they
|
||||
// don't bleed into `document.coreValues` at publish time.
|
||||
resetCustomRuleSelections();
|
||||
|
||||
// Seed the core-values snapshot from the Values section so the
|
||||
// final-review chip modal can edit them (it keys edits by chip id).
|
||||
// The Values entries themselves are then dropped from `sections` to
|
||||
// avoid publishing `document.coreValues` and `document.sections.Values`
|
||||
// for the same data — matches the "Customize" path's data shape.
|
||||
const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc);
|
||||
const sectionsWithoutValues =
|
||||
Object.keys(coreValuesPrefill).length > 0
|
||||
? sections.filter((s) => {
|
||||
const name = (s as { categoryName?: unknown }).categoryName;
|
||||
if (typeof name !== "string") return true;
|
||||
const key = name.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
return key !== "values" && key !== "corevalues";
|
||||
})
|
||||
: sections;
|
||||
|
||||
const summaryRaw =
|
||||
typeof template.description === "string"
|
||||
? template.description.trim()
|
||||
: "";
|
||||
const hasCommunityName =
|
||||
typeof state.title === "string" && state.title.trim().length > 0;
|
||||
updateState({
|
||||
...coreValuesPrefill,
|
||||
sections: sectionsWithoutValues,
|
||||
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
||||
templateReviewBackSlug: templateReviewSlug,
|
||||
...(hasCommunityName
|
||||
? { pendingTemplateAction: undefined }
|
||||
: {
|
||||
pendingTemplateAction: {
|
||||
slug: templateReviewSlug,
|
||||
mode: "useWithoutChanges",
|
||||
},
|
||||
}),
|
||||
});
|
||||
router.push(
|
||||
hasCommunityName
|
||||
? "/create/confirm-stakeholders"
|
||||
: "/create/informational",
|
||||
);
|
||||
}, [
|
||||
resetCustomRuleSelections,
|
||||
router,
|
||||
state.title,
|
||||
templateReviewSlug,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
return {
|
||||
isTemplateReviewRoute,
|
||||
templateReviewSlug,
|
||||
isApplyingTemplate,
|
||||
templateReviewApplyError,
|
||||
setTemplateReviewApplyError,
|
||||
handleCustomize,
|
||||
handleUseWithoutChanges,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user