Navigation, state management, create rule button integration

This commit is contained in:
adilallo
2026-03-02 22:40:29 -07:00
parent 3e3d2881f5
commit 3a3e54d455
17 changed files with 370 additions and 139 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -1,7 +1,7 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import MenuBarItem from "../MenuBarItem";
import Button from "../../buttons/Button";
@@ -20,6 +20,7 @@ export const avatarImages = [
const TopNavContainer = memo<TopNavProps>(
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
const pathname = usePathname();
const router = useRouter();
const t = useTranslation("header");
// Schema markup for site navigation
@@ -164,7 +165,7 @@ const TopNavContainer = memo<TopNavProps>(
size={buttonSize}
buttonType={buttonType}
palette={palette}
href="/create/informational"
onClick={() => router.push("/create/informational")}
ariaLabel={t("ariaLabels.createNewRule")}
>
{renderAvatarGroup(containerSize, avatarSize)}
@@ -20,9 +20,9 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
}) => {
const router = useRouter();
const handleExit = () => {
const handleExit = (options?: { saveDraft?: boolean }) => {
if (onExit) {
onExit();
onExit(options);
} else {
// Default behavior: navigate to home
router.push("/");
@@ -39,9 +39,10 @@ export interface CreateFlowTopNavProps {
*/
onEdit?: () => void;
/**
* Callback when Exit/Save & Exit button is clicked
* Callback when Exit/Save & Exit button is clicked.
* When user is logged in, called with { saveDraft: true } to stub "Save & Exit".
*/
onExit?: () => void;
onExit?: (options?: { saveDraft?: boolean }) => void;
/**
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
* @default "default"
@@ -89,7 +89,7 @@ export function CreateFlowTopNavView({
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onExit}
onClick={() => onExit?.({ saveDraft: loggedIn })}
ariaLabel={exitButtonText}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
+2 -17
View File
@@ -2,27 +2,12 @@
import { notFound } from "next/navigation";
import { use } from "react";
import type { CreateFlowStep } from "../types";
import { VALID_STEPS } from "../utils/flowSteps";
interface PageProps {
params: Promise<{ step: string }>;
}
/**
* Valid step IDs for the create rule flow
*/
const VALID_STEPS: CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
];
/**
* Dynamic route handler for create flow steps
*
@@ -33,7 +18,7 @@ export default function CreateFlowStepPage({ params }: PageProps) {
const { step } = use(params);
// Validate step exists
if (!VALID_STEPS.includes(step as CreateFlowStep)) {
if (!(VALID_STEPS as readonly string[]).includes(step)) {
notFound();
}
+68 -6
View File
@@ -1,6 +1,13 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type {
CreateFlowState,
CreateFlowContextValue,
@@ -9,6 +16,39 @@ import type {
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
const STORAGE_KEY = "create-flow-state";
const DRAFT_STORAGE_KEY = "create-flow-draft";
function readStateFromStorage(key: string): CreateFlowState {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(key);
if (!raw) return {};
const parsed = JSON.parse(raw) as CreateFlowState;
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeStateToStorage(key: string, value: CreateFlowState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore storage errors (e.g. quota, private mode)
}
}
function removeFromStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// Ignore
}
}
interface CreateFlowProviderProps {
children: ReactNode;
initialStep?: CreateFlowStep | null;
@@ -17,27 +57,39 @@ interface CreateFlowProviderProps {
/**
* Provider component for Create Flow state management
*
* This is a basic implementation that will be expanded in CR-56
* with full navigation logic, state persistence, and validation.
* Manages flow state with optional localStorage persistence and draft support.
*/
export function CreateFlowProvider({
children,
initialStep = null,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>({});
const [state, setState] = useState<CreateFlowState>(() =>
readStateFromStorage(STORAGE_KEY),
);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const updateState = (updates: Partial<CreateFlowState>) => {
useEffect(() => {
writeStateToStorage(STORAGE_KEY, state);
}, [state]);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => ({
...prevState,
...updates,
}));
};
}, []);
const clearState = useCallback(() => {
setState({});
removeFromStorage(STORAGE_KEY);
removeFromStorage(DRAFT_STORAGE_KEY);
}, []);
const contextValue: CreateFlowContextValue = {
state,
currentStep,
updateState,
clearState,
};
return (
@@ -47,6 +99,16 @@ export function CreateFlowProvider({
);
}
/** Save current state as draft (e.g. on "Save & Exit"). Stub for CR-57. */
export function saveCreateFlowDraft(state: CreateFlowState): void {
writeStateToStorage(DRAFT_STORAGE_KEY, state);
}
/** Load draft state if present. Caller can merge into initial state when entering flow. */
export function loadCreateFlowDraft(): CreateFlowState {
return readStateFromStorage(DRAFT_STORAGE_KEY);
}
/**
* Hook to access Create Flow context
*
@@ -0,0 +1,81 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import type { CreateFlowStep } from "../types";
import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps";
/**
* Options passed to navigation handlers (e.g. for blur before navigate)
*/
const blurActiveElement = (): void => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
};
/**
* Hook for Create Rule Flow navigation.
*
* Must be used within the create flow (pathname like /create/[step]).
* Uses the current step from the URL and provides type-safe navigation.
*/
export function useCreateFlowNavigation(): {
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
previousStep: CreateFlowStep | null;
} {
const pathname = usePathname();
const router = useRouter();
const currentStep = (pathname?.split("/").pop() ??
null) as CreateFlowStep | null;
const validStep = isValidStep(currentStep) ? currentStep : null;
const nextStep = getNextStep(validStep);
const previousStep = getPreviousStep(validStep);
const goToNextStep = useCallback(() => {
blurActiveElement();
if (nextStep) {
router.push(`/create/${nextStep}`);
}
}, [router, nextStep]);
const goToPreviousStep = useCallback(() => {
blurActiveElement();
if (previousStep) {
router.push(`/create/${previousStep}`);
}
}, [router, previousStep]);
const goToStep = useCallback(
(step: CreateFlowStep) => {
blurActiveElement();
router.push(`/create/${step}`);
},
[router],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
return {
currentStep: validStep,
goToNextStep,
goToPreviousStep,
goToStep,
canGoNext,
canGoBack,
nextStep,
previousStep,
};
}
+12 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import NumberedList from "../../components/type/NumberedList";
@@ -11,8 +12,17 @@ import NumberedList from "../../components/type/NumberedList";
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
*/
export default function InformationalPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const items = [
{
title: "Tell us about your organization",
@@ -39,11 +49,11 @@ export default function InformationalPage() {
title="How CommunityRule helps groups like yours"
description="This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together."
justification="left"
size={isMdOrLarger ? "L" : "M"}
size={effectiveMdOrLarger ? "L" : "M"}
/>
{/* NumberedList: M size at 640px+, S size below 640px */}
<NumberedList items={items} size={isMdOrLarger ? "M" : "S"} />
<NumberedList items={items} size={effectiveMdOrLarger ? "M" : "S"} />
</div>
</div>
);
+33 -67
View File
@@ -1,12 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider } from "./context/CreateFlowContext";
import { useRouter } from "next/navigation";
import {
CreateFlowProvider,
useCreateFlow,
saveCreateFlowDraft,
} from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import type { CreateFlowStep } from "./types";
/**
* Layout for the Create Rule Flow
@@ -16,77 +20,38 @@ import type { CreateFlowStep } from "./types";
* Includes the create flow-specific TopNav and Footer components.
*/
function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
// Extract current step from pathname
const currentStep = pathname?.split("/").pop() as CreateFlowStep | undefined;
// Define step order
const stepOrder: CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
];
// Get next step
const getNextStep = (): CreateFlowStep | null => {
if (!currentStep) return null;
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex === -1 || currentIndex === stepOrder.length - 1) {
return null;
const handleExit = (options?: { saveDraft?: boolean }) => {
const saveDraft = options?.saveDraft ?? false;
if (!saveDraft && typeof window !== "undefined") {
const confirmed = window.confirm(
"Leave create flow? Your progress will be lost.",
);
if (!confirmed) return;
}
return stepOrder[currentIndex + 1];
};
// Get previous step
const getPreviousStep = (): CreateFlowStep | null => {
if (!currentStep) return null;
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex === -1 || currentIndex === 0) {
return null;
}
return stepOrder[currentIndex - 1];
};
const nextStep = getNextStep();
const previousStep = getPreviousStep();
const handleNext = () => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
if (nextStep) {
router.push(`/create/${nextStep}`);
}
};
const handleBack = () => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
if (previousStep) {
router.push(`/create/${previousStep}`);
if (saveDraft) {
saveCreateFlowDraft(state);
}
clearState();
router.push("/");
};
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
return (
<div
className={`bg-black flex flex-col ${isCompletedStep ? "h-screen overflow-hidden" : "min-h-screen"}`}
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
>
<CreateFlowTopNav
hasShare={isCompletedStep}
@@ -98,13 +63,14 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
? () => router.push("/create/final-review")
: undefined
}
onExit={handleExit}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
}
/>
<main
className={`flex-1 flex min-h-0 justify-center ${isCompletedStep ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
className={`flex-1 flex min-h-0 justify-center ${useFullHeightMain ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
>
{children}
</main>
@@ -117,7 +83,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
palette="default"
size="xsmall"
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
onClick={handleNext}
onClick={goToNextStep}
>
{currentStep === "final-review"
? "Finalize CommunityRule"
@@ -125,7 +91,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
</Button>
) : null
}
onBackClick={previousStep ? handleBack : undefined}
onBackClick={previousStep ? goToPreviousStep : undefined}
/>
)}
</div>
+37 -31
View File
@@ -115,35 +115,41 @@ export default function RightRailPage() {
if (showDesktopLayout) {
return (
<div className="w-full flex flex-col items-center px-5 md:px-12">
<div className="flex gap-12 items-stretch w-full max-w-[1280px] min-w-0">
<div className="flex flex-1 flex-col justify-center gap-3 min-w-0">
<DecisionMakingSidebar
title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION}
messageBoxTitle={MESSAGE_BOX_TITLE}
messageBoxItems={MESSAGE_BOX_ITEMS}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size="L"
justification="left"
/>
</div>
<div className="flex flex-1 flex-col gap-6 items-center min-w-0">
<CardStack
cards={SAMPLE_CARDS}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel="See all decision approaches"
showLessLabel="Show less"
title=""
description=""
layout="singleStack"
className="w-full"
/>
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-12 min-h-0 min-w-0 w-full">
{/* Left column: sidebar stays put, does not scroll */}
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
<DecisionMakingSidebar
title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION}
messageBoxTitle={MESSAGE_BOX_TITLE}
messageBoxItems={MESSAGE_BOX_ITEMS}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size="L"
justification="left"
/>
</div>
{/* Right column: card stack — this column scrolls independently */}
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
<div className="py-8 flex flex-col gap-6 items-center min-w-0">
<CardStack
cards={SAMPLE_CARDS}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel="See all decision approaches"
showLessLabel="Show less"
title=""
description=""
layout="singleStack"
className="w-full"
/>
</div>
</div>
</div>
</div>
</div>
@@ -151,8 +157,8 @@ export default function RightRailPage() {
}
return (
<div className="w-full flex flex-col items-center px-5">
<div className="flex flex-col gap-6 w-full min-w-0">
<div className="w-full h-full min-h-0 overflow-y-auto flex flex-col items-center px-5">
<div className="flex flex-col gap-6 w-full min-w-0 py-8">
<DecisionMakingSidebar
title={SIDEBAR_TITLE}
description={SIDEBAR_DESCRIPTION}
+11 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import MultiSelect from "../../components/controls/MultiSelect";
@@ -13,8 +13,17 @@ import MultiSelect from "../../components/controls/MultiSelect";
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
*/
export default function SelectPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
// Sample options for MultiSelect components
const [communitySizeOptions, setCommunitySizeOptions] = useState([
{ id: "1", label: "1 member", state: "Unselected" as const },
@@ -81,7 +90,7 @@ export default function SelectPage() {
return (
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
{isMdOrLarger ? (
{effectiveMdOrLarger ? (
// Two-column layout for 640px+
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
{/* Left column: HeaderLockup */}
+12 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import TextInput from "../../components/controls/TextInput";
@@ -12,9 +12,18 @@ import TextInput from "../../components/controls/TextInput";
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
*/
export default function TextPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
const [value, setValue] = useState("");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const maxLength = 48;
const characterCount = value.length;
@@ -26,7 +35,7 @@ export default function TextPage() {
title="What is your community called?"
description="This will be the name of your community"
justification="left"
size={isMdOrLarger ? "L" : "M"}
size={effectiveMdOrLarger ? "L" : "M"}
/>
{/* TextInput: medium size at 640px+, small size below 640px */}
@@ -35,7 +44,7 @@ export default function TextPage() {
placeholder="Enter your community name"
value={value}
onChange={(e) => setValue(e.target.value)}
inputSize={isMdOrLarger ? "medium" : "small"}
inputSize={effectiveMdOrLarger ? "medium" : "small"}
formHeader={false}
textHint={`${characterCount}/${maxLength}`}
maxLength={maxLength}
+2 -1
View File
@@ -36,7 +36,8 @@ export interface CreateFlowContextValue {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
updateState: (_updates: Partial<CreateFlowState>) => void;
// Navigation handlers will be added in CR-56
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
clearState: () => void;
}
/**
+12 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import Upload from "../../components/controls/Upload";
@@ -12,8 +13,17 @@ import Upload from "../../components/controls/Upload";
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
*/
export default function UploadPage() {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
setIsMounted(true);
}, []);
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
const handleUploadClick = () => {
// TODO: Handle upload button click (e.g. open file picker)
};
@@ -25,8 +35,8 @@ export default function UploadPage() {
<HeaderLockup
title="How should conflicts be resolved?"
description="This will be the name of your community"
justification={isMdOrLarger ? "center" : "left"}
size={isMdOrLarger ? "L" : "M"}
justification={effectiveMdOrLarger ? "center" : "left"}
size={effectiveMdOrLarger ? "L" : "M"}
/>
{/* Upload component: no label in create flow, max width 474px */}
+76
View File
@@ -0,0 +1,76 @@
/**
* Step definitions and helpers for the Create Rule Flow
*
* Single source of truth for step order and navigation helpers.
*/
import type { CreateFlowStep } from "../types";
/**
* Ordered list of steps in the create rule flow
*/
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"informational",
"text",
"select",
"upload",
"review",
"cards",
"right-rail",
"final-review",
"completed",
] as const;
/**
* Valid step IDs for the create flow (for validation)
*/
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
/**
* First step in the flow (entry point)
*/
export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/**
* Returns the next step in the flow, or null if current is last/invalid
*/
export function getNextStep(
currentStep: CreateFlowStep | null | undefined,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
return FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
}
/**
* Returns the previous step in the flow, or null if current is first/invalid
*/
export function getPreviousStep(
currentStep: CreateFlowStep | null | undefined,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null;
return FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
}
/**
* Returns the index of the step (0-based), or -1 if invalid
*/
export function getStepIndex(step: CreateFlowStep | null | undefined): number {
if (!step) return -1;
return FLOW_STEP_ORDER.indexOf(step);
}
/**
* Whether the given string is a valid create flow step
*/
export function isValidStep(
step: string | null | undefined,
): step is CreateFlowStep {
return (
typeof step === "string" &&
(VALID_STEPS as readonly string[]).includes(step)
);
}
+14
View File
@@ -1,7 +1,21 @@
import React from "react";
import { vi } from "vitest";
import TopNav from "../../app/components/navigation/TopNav";
import { componentTestSuite } from "../utils/componentTestSuite";
// Mock next/navigation (TopNav uses useRouter for Create Rule button and usePathname for nav state)
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/",
}));
type TopNavProps = React.ComponentProps<typeof TopNav>;
// Test folderTop=false variant (standard header)