Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.
Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,8 +23,8 @@ import {
|
|||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getStepIndex,
|
|
||||||
parseReviewReturnSearchParam,
|
parseReviewReturnSearchParam,
|
||||||
|
shouldOfferCreateFlowSaveAndExit,
|
||||||
createFlowStepUsesSelectSplitScroll,
|
createFlowStepUsesSelectSplitScroll,
|
||||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
@@ -64,6 +64,10 @@ import {
|
|||||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
import {
|
||||||
|
clearPendingKeepRuleLogin,
|
||||||
|
hasPendingKeepRuleLogin,
|
||||||
|
} from "../../../lib/create/pendingKeepRuleLogin";
|
||||||
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import {
|
import {
|
||||||
@@ -91,9 +95,6 @@ import {
|
|||||||
useCreateFlowDraftSaveBanner,
|
useCreateFlowDraftSaveBanner,
|
||||||
} from "./context/CreateFlowDraftSaveBannerContext";
|
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||||
|
|
||||||
/** First step where Save & Exit is offered (first Create Community select per Figma). */
|
|
||||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
|
||||||
|
|
||||||
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||||
const [sessionUser, setSessionUser] = useState<
|
const [sessionUser, setSessionUser] = useState<
|
||||||
{ id: string; email: string } | null | undefined
|
{ id: string; email: string } | null | undefined
|
||||||
@@ -111,9 +112,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const sessionResolved = sessionUser !== undefined;
|
const sessionResolved = sessionUser !== undefined;
|
||||||
// Mirror in-progress draft to localStorage for ALL visitors once we know who
|
// Mirror in-progress draft to localStorage for ALL visitors once we know who
|
||||||
// they are. Refresh-survival is the same UX for guest and signed-in users;
|
// they are. Refresh-survival is the same UX for guest and signed-in users.
|
||||||
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
|
// Save & Exit: guests open the save-progress login modal; signed-in users
|
||||||
// the server (handled in `useCreateFlowExit`).
|
// PUT the server draft (`useCreateFlowExit`).
|
||||||
const enableLocalDraftMirroring = sessionResolved;
|
const enableLocalDraftMirroring = sessionResolved;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -197,6 +198,8 @@ function CreateFlowLayoutContent({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||||
|
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
|
||||||
|
useState(false);
|
||||||
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
|
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -267,14 +270,14 @@ function CreateFlowLayoutContent({
|
|||||||
title: completedCopy.guestInvitesSkippedTitle,
|
title: completedCopy.guestInvitesSkippedTitle,
|
||||||
description: completedCopy.guestInvitesSkippedDescription,
|
description: completedCopy.guestInvitesSkippedDescription,
|
||||||
});
|
});
|
||||||
return;
|
} else {
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "guestClaimHint",
|
||||||
|
status: "warning",
|
||||||
|
title: completedCopy.guestClaimHintTitle,
|
||||||
|
description: completedCopy.guestClaimHintDescription,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setCompletedFlowBanner({
|
|
||||||
key: "guestClaimHint",
|
|
||||||
status: "warning",
|
|
||||||
title: completedCopy.guestClaimHintTitle,
|
|
||||||
description: completedCopy.guestClaimHintDescription,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,14 +308,26 @@ function CreateFlowLayoutContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||||
const saveDraft = opts?.saveDraft ?? false;
|
|
||||||
if (!sessionResolved) return;
|
if (!sessionResolved) return;
|
||||||
|
|
||||||
// Exit from `/create/completed` is post-publish: the rule is saved, so we
|
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule
|
||||||
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
|
// login (the document is live but unclaimed). Signed-in users just leave.
|
||||||
// For signed-in users we also DELETE the server draft so a future visit to
|
|
||||||
// /create starts fresh instead of rehydrating yesterday's work.
|
|
||||||
if (currentStep === "completed") {
|
if (currentStep === "completed") {
|
||||||
|
if (sessionUser === null) {
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: CREATE_ROUTES.completed,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
onDismiss: () => {
|
||||||
|
runCompletedStepExit({
|
||||||
|
clearState,
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
runCompletedStepExit({
|
runCompletedStepExit({
|
||||||
clearState,
|
clearState,
|
||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
@@ -322,7 +337,6 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionUser === null) {
|
if (sessionUser === null) {
|
||||||
if (saveDraft) return;
|
|
||||||
const returnToTemplateReview =
|
const returnToTemplateReview =
|
||||||
templateReviewSlug != null
|
templateReviewSlug != null
|
||||||
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
||||||
@@ -428,9 +442,32 @@ function CreateFlowLayoutContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep !== "completed") {
|
if (currentStep !== "completed") {
|
||||||
setCompletedFlowBanner(null);
|
setCompletedFlowBanner(null);
|
||||||
|
setCompletedHasPublishedRule(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== "completed") return;
|
||||||
|
if (!sessionResolved) return;
|
||||||
|
if (sessionUser !== null) {
|
||||||
|
clearPendingKeepRuleLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasPendingKeepRuleLogin()) return;
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (!hasPendingKeepRuleLogin()) return;
|
||||||
|
clearPendingKeepRuleLogin();
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: CREATE_ROUTES.completed,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
setCommunitySaveMagicLinkSuccess(false);
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
@@ -486,7 +523,6 @@ function CreateFlowLayoutContent({
|
|||||||
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||||
currentStep,
|
currentStep,
|
||||||
);
|
);
|
||||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
|
||||||
|
|
||||||
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
|
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
|
||||||
const mainContentClass = isCompletedStep
|
const mainContentClass = isCompletedStep
|
||||||
@@ -506,9 +542,10 @@ function CreateFlowLayoutContent({
|
|||||||
? "max-md:flex-col max-md:items-stretch"
|
? "max-md:flex-col max-md:items-stretch"
|
||||||
: "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 = shouldOfferCreateFlowSaveAndExit(
|
||||||
Boolean(sessionUser) &&
|
currentStep,
|
||||||
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
|
sessionUser,
|
||||||
|
);
|
||||||
|
|
||||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -674,7 +711,10 @@ function CreateFlowLayoutContent({
|
|||||||
<CreateFlowTopNav
|
<CreateFlowTopNav
|
||||||
hasShare={isCompletedStep}
|
hasShare={isCompletedStep}
|
||||||
hasExport={isCompletedStep}
|
hasExport={isCompletedStep}
|
||||||
hasEdit={isCompletedStep && Boolean(sessionUser)}
|
hasEdit={
|
||||||
|
isCompletedStep &&
|
||||||
|
(Boolean(sessionUser) || completedHasPublishedRule)
|
||||||
|
}
|
||||||
hasManageStakeholders={isEditRuleStep}
|
hasManageStakeholders={isEditRuleStep}
|
||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onShare={
|
onShare={
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
|||||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
import type { CreateFlowState } from "../types";
|
import type { CreateFlowState } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
|
|||||||
type AppRouterLike = { push: (_href: string) => void };
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
|
||||||
type OpenLogin = (args: {
|
type OpenLogin = (args: {
|
||||||
variant: "default" | "saveProgress";
|
variant: "default" | "saveProgress" | "keepRule";
|
||||||
nextPath: string;
|
nextPath: string;
|
||||||
backdropVariant: "blurredYellow";
|
backdropVariant: "blurredYellow";
|
||||||
}) => void;
|
}) => void;
|
||||||
@@ -79,6 +80,21 @@ export function useCreateFlowFinalize({
|
|||||||
? state.editingPublishedRuleId.trim()
|
? state.editingPublishedRuleId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
if (editingId.length > 0 && sessionUser === null) {
|
||||||
|
setIsPublishing(false);
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
updateState({ editingPublishedRuleId: undefined });
|
||||||
|
writePendingKeepRuleLogin();
|
||||||
|
onGuestPublished?.({ skippedInvites: false });
|
||||||
|
router.push(createFlowStepPath("completed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editingId.length > 0) {
|
if (editingId.length > 0) {
|
||||||
const updateResult = await updatePublishedRule(editingId, {
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
title,
|
title,
|
||||||
@@ -99,8 +115,11 @@ export function useCreateFlowFinalize({
|
|||||||
}
|
}
|
||||||
if (updateResult.status === 401) {
|
if (updateResult.status === 401) {
|
||||||
openLogin({
|
openLogin({
|
||||||
variant: "default",
|
variant: sessionUser === null ? "keepRule" : "default",
|
||||||
nextPath: loginReturnPath,
|
nextPath:
|
||||||
|
sessionUser === null
|
||||||
|
? createFlowStepPath("completed")
|
||||||
|
: loginReturnPath,
|
||||||
backdropVariant: "blurredYellow",
|
backdropVariant: "blurredYellow",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -135,6 +154,7 @@ export function useCreateFlowFinalize({
|
|||||||
document: ruleDocument,
|
document: ruleDocument,
|
||||||
});
|
});
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
|
writePendingKeepRuleLogin();
|
||||||
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
|
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
|
||||||
}
|
}
|
||||||
router.push(
|
router.push(
|
||||||
@@ -147,8 +167,11 @@ export function useCreateFlowFinalize({
|
|||||||
}
|
}
|
||||||
if (publishResult.status === 401) {
|
if (publishResult.status === 401) {
|
||||||
openLogin({
|
openLogin({
|
||||||
variant: "default",
|
variant: sessionUser === null ? "keepRule" : "default",
|
||||||
nextPath: loginReturnPath,
|
nextPath:
|
||||||
|
sessionUser === null
|
||||||
|
? createFlowStepPath("completed")
|
||||||
|
: loginReturnPath,
|
||||||
backdropVariant: "blurredYellow",
|
backdropVariant: "blurredYellow",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -279,9 +279,8 @@ export interface CreateFlowContextValue {
|
|||||||
*
|
*
|
||||||
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
||||||
* has already started editing, we skip replaying their server draft on top
|
* has already started editing, we skip replaying their server draft on top
|
||||||
* of in-progress local state. Save & Exit visibility is driven by step
|
* of in-progress local state. Save & Exit visibility is
|
||||||
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
|
* `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
|
||||||
* flag.
|
|
||||||
*/
|
*/
|
||||||
interactionTouched: boolean;
|
interactionTouched: boolean;
|
||||||
markCreateFlowInteraction: () => void;
|
markCreateFlowInteraction: () => void;
|
||||||
|
|||||||
@@ -117,6 +117,27 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
|||||||
return FLOW_STEP_ORDER.indexOf(step);
|
return FLOW_STEP_ORDER.indexOf(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First wizard step that offers Save & Exit (first Create Community select). */
|
||||||
|
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
|
||||||
|
* `community-structure` onward and on `edit-rule`. On completed, guests still
|
||||||
|
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
|
||||||
|
*
|
||||||
|
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
|
||||||
|
* not a guest — completed stays Exit until the session resolves.
|
||||||
|
*/
|
||||||
|
export function shouldOfferCreateFlowSaveAndExit(
|
||||||
|
currentStep: CreateFlowStep | null | undefined,
|
||||||
|
sessionUser?: { id: string } | null,
|
||||||
|
): boolean {
|
||||||
|
if (currentStep == null) return false;
|
||||||
|
if (currentStep === "completed") return sessionUser === null;
|
||||||
|
if (currentStep === "edit-rule") return true;
|
||||||
|
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steps where below `lg` the main column scrolls with split layout
|
* Steps where below `lg` the main column scrolls with split layout
|
||||||
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function LoginView({
|
|||||||
<div
|
<div
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
|
className={`fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8 ${backdropClasses[backdropVariant]}`}
|
||||||
onClick={onClose}
|
onPointerDown={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -44,17 +44,51 @@ function MailIconInline() {
|
|||||||
|
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export type LoginFormVariant = "default" | "saveProgress";
|
export type LoginFormVariant = "default" | "saveProgress" | "keepRule";
|
||||||
|
|
||||||
|
function loginFormHeading(
|
||||||
|
variant: LoginFormVariant,
|
||||||
|
sent: boolean,
|
||||||
|
t: (_key: string) => string,
|
||||||
|
): { title: string; description: string } {
|
||||||
|
if (sent) {
|
||||||
|
return { title: t("successTitle"), description: t("successBody") };
|
||||||
|
}
|
||||||
|
if (variant === "saveProgress") {
|
||||||
|
return {
|
||||||
|
title: t("saveProgressTitle"),
|
||||||
|
description: t("saveProgressSubtitle"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (variant === "keepRule") {
|
||||||
|
return {
|
||||||
|
title: t("keepRuleTitle"),
|
||||||
|
description: t("keepRuleSubtitle"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { title: t("title"), description: t("subtitle") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAttachCreateFlowDraft(
|
||||||
|
variant: LoginFormVariant,
|
||||||
|
nextPath: string,
|
||||||
|
): boolean {
|
||||||
|
if (variant === "keepRule") return false;
|
||||||
|
return variant === "saveProgress" || nextPath.includes("syncDraft=1");
|
||||||
|
}
|
||||||
|
|
||||||
export type LoginFormProps = {
|
export type LoginFormProps = {
|
||||||
variant?: LoginFormVariant;
|
variant?: LoginFormVariant;
|
||||||
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
|
/** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */
|
||||||
magicLinkNextPath?: string;
|
magicLinkNextPath?: string;
|
||||||
|
/** `keepRule`: Continue without saving. */
|
||||||
|
onDismiss?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginForm({
|
export default function LoginForm({
|
||||||
variant = "default",
|
variant = "default",
|
||||||
magicLinkNextPath,
|
magicLinkNextPath,
|
||||||
|
onDismiss,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
const t = useTranslation("pages.login");
|
const t = useTranslation("pages.login");
|
||||||
const tFooter = useTranslation("footer");
|
const tFooter = useTranslation("footer");
|
||||||
@@ -72,7 +106,7 @@ export default function LoginForm({
|
|||||||
const nextParam = searchParams.get("next");
|
const nextParam = searchParams.get("next");
|
||||||
const errorParam = searchParams.get("error");
|
const errorParam = searchParams.get("error");
|
||||||
|
|
||||||
const isSaveProgress = variant === "saveProgress";
|
const heading = loginFormHeading(variant, sent, t);
|
||||||
|
|
||||||
/** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */
|
/** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */
|
||||||
const stripErrorQuery = useCallback(() => {
|
const stripErrorQuery = useCallback(() => {
|
||||||
@@ -96,8 +130,7 @@ export default function LoginForm({
|
|||||||
try {
|
try {
|
||||||
const rawNext = magicLinkNextPath ?? nextParam;
|
const rawNext = magicLinkNextPath ?? nextParam;
|
||||||
const nextPath = safeInternalPath(rawNext);
|
const nextPath = safeInternalPath(rawNext);
|
||||||
const shouldAttachDraft =
|
const shouldAttachDraft = shouldAttachCreateFlowDraft(variant, nextPath);
|
||||||
isSaveProgress || nextPath.includes("syncDraft=1");
|
|
||||||
const localDraft = readAnonymousCreateFlowState();
|
const localDraft = readAnonymousCreateFlowState();
|
||||||
const draft =
|
const draft =
|
||||||
shouldAttachDraft && Object.keys(localDraft).length > 0
|
shouldAttachDraft && Object.keys(localDraft).length > 0
|
||||||
@@ -115,7 +148,7 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSaveProgress || nextPath.includes("syncDraft=1")) {
|
if (shouldAttachDraft) {
|
||||||
setTransferPendingFlag();
|
setTransferPendingFlag();
|
||||||
}
|
}
|
||||||
setEmail(trimmed);
|
setEmail(trimmed);
|
||||||
@@ -127,11 +160,11 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
email,
|
email,
|
||||||
isSaveProgress,
|
|
||||||
magicLinkNextPath,
|
magicLinkNextPath,
|
||||||
nextParam,
|
nextParam,
|
||||||
stripErrorQuery,
|
stripErrorQuery,
|
||||||
t,
|
t,
|
||||||
|
variant,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const urlErrorMessage =
|
const urlErrorMessage =
|
||||||
@@ -155,20 +188,8 @@ export default function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
titleId={titleId}
|
titleId={titleId}
|
||||||
title={
|
title={heading.title}
|
||||||
sent
|
description={heading.description}
|
||||||
? t("successTitle")
|
|
||||||
: isSaveProgress
|
|
||||||
? t("saveProgressTitle")
|
|
||||||
: t("title")
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
sent
|
|
||||||
? t("successBody")
|
|
||||||
: isSaveProgress
|
|
||||||
? t("saveProgressSubtitle")
|
|
||||||
: t("subtitle")
|
|
||||||
}
|
|
||||||
variant="login"
|
variant="login"
|
||||||
alignment="left"
|
alignment="left"
|
||||||
/>
|
/>
|
||||||
@@ -255,6 +276,19 @@ export default function LoginForm({
|
|||||||
>
|
>
|
||||||
{t("sendMagicLink")}
|
{t("sendMagicLink")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{onDismiss ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="large"
|
||||||
|
buttonType="ghost"
|
||||||
|
palette="default"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
|
||||||
|
>
|
||||||
|
{t("continueWithoutSaving")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
<p className="text-center text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
{t("legalPrefix")}
|
{t("legalPrefix")}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import Login from "../components/modals/Login";
|
import Login from "../components/modals/Login";
|
||||||
import LoginForm from "../components/modals/Login/LoginForm";
|
import LoginForm from "../components/modals/Login/LoginForm";
|
||||||
|
|
||||||
export type AuthModalLoginVariant = "default" | "saveProgress";
|
export type AuthModalLoginVariant = "default" | "saveProgress" | "keepRule";
|
||||||
|
|
||||||
export type AuthModalBackdropVariant = "solid" | "blurredYellow";
|
export type AuthModalBackdropVariant = "solid" | "blurredYellow";
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ export type OpenLoginOptions = {
|
|||||||
/** Passed to `requestMagicLink` as `next` (internal path). */
|
/** Passed to `requestMagicLink` as `next` (internal path). */
|
||||||
nextPath?: string;
|
nextPath?: string;
|
||||||
backdropVariant?: AuthModalBackdropVariant;
|
backdropVariant?: AuthModalBackdropVariant;
|
||||||
|
/**
|
||||||
|
* `keepRule` only: **Continue without saving**. Default is close the overlay.
|
||||||
|
* Guest completed Save & Exit passes leave-the-flow.
|
||||||
|
*/
|
||||||
|
onDismiss?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthModalContextValue = {
|
type AuthModalContextValue = {
|
||||||
@@ -49,6 +54,14 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const backdropVariant = opts.backdropVariant ?? "blurredYellow";
|
const backdropVariant = opts.backdropVariant ?? "blurredYellow";
|
||||||
|
const keepRuleDismiss =
|
||||||
|
opts.variant === "keepRule"
|
||||||
|
? () => {
|
||||||
|
const extra = opts.onDismiss;
|
||||||
|
closeLogin();
|
||||||
|
extra?.();
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthModalContext.Provider value={value}>
|
<AuthModalContext.Provider value={value}>
|
||||||
@@ -63,6 +76,7 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
|||||||
<LoginForm
|
<LoginForm
|
||||||
variant={opts.variant ?? "default"}
|
variant={opts.variant ?? "default"}
|
||||||
magicLinkNextPath={opts.nextPath}
|
magicLinkNextPath={opts.nextPath}
|
||||||
|
onDismiss={keepRuleDismiss}
|
||||||
/>
|
/>
|
||||||
</Login>
|
</Login>
|
||||||
</AuthModalContext.Provider>
|
</AuthModalContext.Provider>
|
||||||
|
|||||||
+3
-3
@@ -61,7 +61,7 @@ The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PU
|
|||||||
|
|
||||||
### Fresh start vs continue draft (signed-in + sync)
|
### Fresh start vs continue draft (signed-in + sync)
|
||||||
|
|
||||||
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Signed-in users additionally get **Save & Exit**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Both see **Save & Exit** from `community-structure` onward (and `edit-rule`). On **`/create/completed`**, guests still get **Save & Exit** (keep-this-rule magic-link modal); signed-in users get **Exit**. Wizard guests open the save-progress magic-link modal; signed-in users may **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||||
|
|
||||||
- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped.
|
- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped.
|
||||||
- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path.
|
- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path.
|
||||||
@@ -105,8 +105,8 @@ Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templat
|
|||||||
|
|
||||||
| Mode | Where progress lives | Save & Exit / publish |
|
| Mode | Where progress lives | Save & Exit / publish |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). |
|
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Save & Exit** (from `community-structure` onward, plus `edit-rule`) opens the save-progress magic-link modal; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. Landing on **`/create/completed`** opens a **keep-this-rule** magic-link modal (no draft transfer); the top nav stays **Save & Exit** so they can reopen it (**Continue without saving** from that control leaves the flow). After Finalize, skip on the auto-opened modal stays on completed. **Edit** shows when a last-published rule is in session storage. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). |
|
||||||
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. |
|
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (and `edit-rule`) may **PUT** `/api/drafts/me` when sync is on. **Completed** is **Exit**. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. |
|
||||||
|
|
||||||
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
|
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Guest finalize → `/create/completed` keep-this-rule login. Stored in
|
||||||
|
* sessionStorage so the prompt survives create-layout remounts and is not
|
||||||
|
* tied to the Finalize click (that click would otherwise dismiss the overlay).
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY =
|
||||||
|
"createFlow.pendingKeepRuleLogin";
|
||||||
|
|
||||||
|
export function writePendingKeepRuleLogin(): void {
|
||||||
|
if (typeof sessionStorage === "undefined") return;
|
||||||
|
sessionStorage.setItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingKeepRuleLogin(): boolean {
|
||||||
|
if (typeof sessionStorage === "undefined") return false;
|
||||||
|
return sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingKeepRuleLogin(): void {
|
||||||
|
if (typeof sessionStorage === "undefined") return;
|
||||||
|
sessionStorage.removeItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY);
|
||||||
|
}
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
"guestInvitesSkippedTitle": "Stakeholder invites were not sent",
|
"guestInvitesSkippedTitle": "Stakeholder invites were not sent",
|
||||||
"guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.",
|
"guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.",
|
||||||
"guestClaimHintTitle": "Sign in to keep this rule on your profile",
|
"guestClaimHintTitle": "Sign in to keep this rule on your profile",
|
||||||
"guestClaimHintDescription": "Use Log in on this same browser to attach the rule to your account so you can edit it later."
|
"guestClaimHintDescription": "Sign in on this same browser to attach the rule to your account so you can edit it later."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
|
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
|
||||||
"saveProgressTitle": "Save your progress?",
|
"saveProgressTitle": "Save your progress?",
|
||||||
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.",
|
"saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.",
|
||||||
|
"keepRuleTitle": "Sign in to keep this rule on your profile",
|
||||||
|
"keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.",
|
||||||
|
"continueWithoutSaving": "Continue without saving",
|
||||||
"emailLabel": "Email address",
|
"emailLabel": "Email address",
|
||||||
"emailPlaceholder": "you@example.com",
|
"emailPlaceholder": "you@example.com",
|
||||||
"sendMagicLink": "Send me a magic link",
|
"sendMagicLink": "Send me a magic link",
|
||||||
|
|||||||
@@ -110,6 +110,26 @@ describe("Login", () => {
|
|||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes on backdrop pointerdown, not a leftover click", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||||
|
<p id="login-modal-heading">Body</p>
|
||||||
|
</Login>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const backdrop = screen.getByRole("dialog").parentElement;
|
||||||
|
expect(backdrop).not.toBeNull();
|
||||||
|
fireEvent.click(backdrop!);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerDown(screen.getByRole("dialog"));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerDown(backdrop!);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("locks body scroll while open", async () => {
|
it("locks body scroll while open", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||||
|
|||||||
@@ -174,6 +174,70 @@ describe("LoginForm", () => {
|
|||||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keepRule variant claims without attaching a create-flow draft", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"create-flow-anonymous",
|
||||||
|
JSON.stringify({ title: "Guest draft" }),
|
||||||
|
);
|
||||||
|
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginForm
|
||||||
|
variant="keepRule"
|
||||||
|
magicLinkNextPath="/create/completed"
|
||||||
|
/>
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
name: /sign in to keep this rule on your profile/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
|
"guest@example.com",
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||||
|
"guest@example.com",
|
||||||
|
"/create/completed",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(setTransferPendingFlag).not.toHaveBeenCalled();
|
||||||
|
window.localStorage.removeItem("create-flow-anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keepRule continue without saving calls onDismiss", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginForm
|
||||||
|
variant="keepRule"
|
||||||
|
magicLinkNextPath="/create/completed"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /continue without saving/i }),
|
||||||
|
);
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(requestMagicLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default variant has no continue without saving action", () => {
|
||||||
|
renderLoginForm();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /continue without saving/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("passes safe next path when next query param is set", async () => {
|
it("passes safe next path when next query param is set", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
navMock.searchParams = new URLSearchParams("next=/learn");
|
navMock.searchParams = new URLSearchParams("next=/learn");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { screen, waitFor } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
@@ -47,6 +47,7 @@ import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDr
|
|||||||
|
|
||||||
function LoginTrigger() {
|
function LoginTrigger() {
|
||||||
const { openLogin, closeLogin } = useAuthModal();
|
const { openLogin, closeLogin } = useAuthModal();
|
||||||
|
const [leftCompleted, setLeftCompleted] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={() => openLogin()}>
|
<button type="button" onClick={() => openLogin()}>
|
||||||
@@ -63,9 +64,33 @@ function LoginTrigger() {
|
|||||||
>
|
>
|
||||||
Open save progress
|
Open save progress
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: "/create/completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open keep rule
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: "/create/completed",
|
||||||
|
onDismiss: () => setLeftCompleted(true),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open keep rule then leave
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => closeLogin()}>
|
<button type="button" onClick={() => closeLogin()}>
|
||||||
Close from outside
|
Close from outside
|
||||||
</button>
|
</button>
|
||||||
|
{leftCompleted ? <p>Left completed</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,6 +158,9 @@ describe("AuthModalProvider (header overlay)", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /continue without saving/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByRole("textbox", { name: /email address/i }),
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
"guest@example.com",
|
"guest@example.com",
|
||||||
@@ -149,4 +177,80 @@ describe("AuthModalProvider (header overlay)", () => {
|
|||||||
});
|
});
|
||||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keepRule openLogin does not set transfer pending", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginTrigger />
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /^open keep rule$/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
name: /sign in to keep this rule on your profile/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
|
"guest@example.com",
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||||
|
"guest@example.com",
|
||||||
|
"/create/completed",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(setTransferPendingFlag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keepRule continue without saving closes the overlay", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginTrigger />
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /^open keep rule$/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /continue without saving/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Left completed")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keepRule onDismiss runs when continue without saving is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginTrigger />
|
||||||
|
</Suspense>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /open keep rule then leave/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /continue without saving/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Left completed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getStepIndex,
|
getStepIndex,
|
||||||
parseReviewReturnSearchParam,
|
parseReviewReturnSearchParam,
|
||||||
resolveCreateFlowBackTarget,
|
resolveCreateFlowBackTarget,
|
||||||
|
shouldOfferCreateFlowSaveAndExit,
|
||||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
@@ -132,4 +133,21 @@ describe("flowSteps", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(parseReviewReturnSearchParam(null)).toBeNull();
|
expect(parseReviewReturnSearchParam(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Save & Exit from community-structure through final-review and on edit-rule", () => {
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(true);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("final-review")).toBe(true);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("edit-rule")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Save & Exit on completed only for guests", () => {
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("completed")).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldOfferCreateFlowSaveAndExit("completed", { id: "user-1" }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true);
|
||||||
|
expect(shouldOfferCreateFlowSaveAndExit(null)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CreateFlowState } from "../../../app/(app)/create/types";
|
|||||||
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
|
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
|
||||||
import { publishRule, updatePublishedRule } from "../../../lib/create/api";
|
import { publishRule, updatePublishedRule } from "../../../lib/create/api";
|
||||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||||
@@ -44,6 +45,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
updateState.mockReset();
|
updateState.mockReset();
|
||||||
openLogin.mockReset();
|
openLogin.mockReset();
|
||||||
onGuestPublished.mockReset();
|
onGuestPublished.mockReset();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -82,6 +84,9 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
summary: "Published summary",
|
summary: "Published summary",
|
||||||
document: {},
|
document: {},
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not publish while the session is unresolved", async () => {
|
it("does not publish while the session is unresolved", async () => {
|
||||||
@@ -137,6 +142,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
});
|
});
|
||||||
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true });
|
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true });
|
||||||
expect(openLogin).not.toHaveBeenCalled();
|
expect(openLogin).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
|
||||||
expect(router.push).toHaveBeenCalledWith(
|
expect(router.push).toHaveBeenCalledWith(
|
||||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||||
);
|
);
|
||||||
@@ -172,6 +178,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
});
|
});
|
||||||
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
|
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
|
||||||
expect(openLogin).not.toHaveBeenCalled();
|
expect(openLogin).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
|
||||||
expect(router.push).toHaveBeenCalledWith(
|
expect(router.push).toHaveBeenCalledWith(
|
||||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||||
);
|
);
|
||||||
@@ -250,4 +257,69 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
editingPublishedRuleId: undefined,
|
editingPublishedRuleId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not PATCH when a guest finalizes an already-published rule", async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCreateFlowFinalize({
|
||||||
|
state: {
|
||||||
|
...emptyState,
|
||||||
|
editingPublishedRuleId: "guest-rule-1",
|
||||||
|
},
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath: "/create/final-review?syncDraft=1",
|
||||||
|
sessionUser: null,
|
||||||
|
onGuestPublished,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.finalize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatePublishedRule).not.toHaveBeenCalled();
|
||||||
|
expect(publishRule).not.toHaveBeenCalled();
|
||||||
|
expect(openLogin).not.toHaveBeenCalled();
|
||||||
|
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
|
||||||
|
expect(writeLastPublishedRule).toHaveBeenCalledWith({
|
||||||
|
id: "guest-rule-1",
|
||||||
|
title: "Published title",
|
||||||
|
summary: "Published summary",
|
||||||
|
document: {},
|
||||||
|
});
|
||||||
|
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1");
|
||||||
|
expect(router.push).toHaveBeenCalledWith("/create/completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens keepRule login when a guest publish is unauthorized", async () => {
|
||||||
|
vi.mocked(publishRule).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCreateFlowFinalize({
|
||||||
|
state: emptyState,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath: "/create/final-review?syncDraft=1",
|
||||||
|
sessionUser: null,
|
||||||
|
onGuestPublished,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.finalize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openLogin).toHaveBeenCalledWith({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: "/create/completed",
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
expect(router.push).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY,
|
||||||
|
clearPendingKeepRuleLogin,
|
||||||
|
hasPendingKeepRuleLogin,
|
||||||
|
writePendingKeepRuleLogin,
|
||||||
|
} from "../../lib/create/pendingKeepRuleLogin";
|
||||||
|
|
||||||
|
describe("pendingKeepRuleLogin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes, reads, and clears the session flag", () => {
|
||||||
|
expect(hasPendingKeepRuleLogin()).toBe(false);
|
||||||
|
writePendingKeepRuleLogin();
|
||||||
|
expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe(
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
expect(hasPendingKeepRuleLogin()).toBe(true);
|
||||||
|
clearPendingKeepRuleLogin();
|
||||||
|
expect(hasPendingKeepRuleLogin()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user