Compare commits
5
Commits
3f9a8395be
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b0ec8ad8 | ||
|
|
98945ceb7e | ||
|
|
234f3998ad | ||
|
|
6ccc1e8c8e | ||
|
|
90db213413 |
@@ -27,8 +27,7 @@ import {
|
||||
shouldOfferCreateFlowSaveAndExit,
|
||||
isDirectTemplateReviewEntry,
|
||||
createFlowStepUsesSelectSplitScroll,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
isCreateFlowTemplatesPickerPath,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "./utils/flowSteps";
|
||||
@@ -309,6 +308,8 @@ function CreateFlowLayoutContent({
|
||||
fromCreateWizard,
|
||||
markCreateFlowInteraction,
|
||||
});
|
||||
const isCreateTemplatesPickerRoute =
|
||||
isCreateFlowTemplatesPickerPath(pathname);
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
@@ -566,9 +567,11 @@ function CreateFlowLayoutContent({
|
||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||
: isSelectSplitScrollStep
|
||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
|
||||
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
: isCreateTemplatesPickerRoute
|
||||
? "items-start justify-start overflow-y-auto"
|
||||
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
|
||||
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
|
||||
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
||||
const mainMaxMdJustify =
|
||||
@@ -579,10 +582,9 @@ function CreateFlowLayoutContent({
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit(
|
||||
currentStep,
|
||||
sessionUser,
|
||||
);
|
||||
const saveDraftOnExit =
|
||||
shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
|
||||
isCreateTemplatesPickerRoute;
|
||||
|
||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||
currentStep,
|
||||
@@ -814,9 +816,11 @@ function CreateFlowLayoutContent({
|
||||
contentMaxClass={getCreateFlowContentMaxClass({
|
||||
step: currentStep,
|
||||
isTemplateReview: isTemplateReviewRoute,
|
||||
isTemplatesPicker: isCreateTemplatesPickerRoute,
|
||||
})}
|
||||
progressBar={
|
||||
!isTemplateReviewRoute &&
|
||||
!isCreateTemplatesPickerRoute &&
|
||||
!isFinalReviewLike &&
|
||||
reviewReturnTarget !== "edit-rule"
|
||||
}
|
||||
@@ -920,15 +924,7 @@ function CreateFlowLayoutContent({
|
||||
disabled={isPublishing}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
// `fromFlow=1` tells `/templates` to skip the fresh-slate
|
||||
// draft clear it normally runs on template click, so the
|
||||
// user's in-progress Create Community stage survives this
|
||||
// detour. Direct entries to `/templates` (no marker) and
|
||||
// home "Popular templates" clicks always start fresh by
|
||||
// wiping anonymous draft storage at click time.
|
||||
router.push(
|
||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||
);
|
||||
router.push(CREATE_ROUTES.templatesPicker);
|
||||
}}
|
||||
>
|
||||
{footer.createFromTemplate}
|
||||
@@ -1013,7 +1009,11 @@ function CreateFlowLayoutContent({
|
||||
) : null
|
||||
}
|
||||
onBackClick={
|
||||
isTemplateReviewRoute
|
||||
isCreateTemplatesPickerRoute
|
||||
? () => {
|
||||
router.push(CREATE_ROUTES.review);
|
||||
}
|
||||
: isTemplateReviewRoute
|
||||
? () =>
|
||||
router.push(
|
||||
templateReviewFooterBackToCreateReview
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutToke
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import {
|
||||
isValidStep,
|
||||
isCreateFlowTemplatesPickerPath,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
} from "./utils/flowSteps";
|
||||
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
||||
@@ -111,6 +112,9 @@ export function SignedInDraftHydration({
|
||||
if (pathname?.includes("/create/review-template/")) {
|
||||
return;
|
||||
}
|
||||
if (isCreateFlowTemplatesPickerPath(pathname)) {
|
||||
return;
|
||||
}
|
||||
if (touchedRef.current) {
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
CreateFlowUploadValidationError,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { updateState } = useCreateFlow();
|
||||
const { state, updateState } = useCreateFlow();
|
||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||
const hasServerAvatar =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionResolved || !sessionUser) return;
|
||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||
if (hasServerAvatar) return;
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
|
||||
await clearPendingCommunityAvatarFile();
|
||||
updateState({ communityAvatarUrl: url });
|
||||
lastFlushedUserIdRef.current = sessionUser.id;
|
||||
} catch {
|
||||
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
||||
} catch (err) {
|
||||
if (err instanceof CreateFlowUploadValidationError) {
|
||||
await clearPendingCommunityAvatarFile();
|
||||
}
|
||||
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, updateState]);
|
||||
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+6
-3
@@ -3,7 +3,10 @@
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
createFlowUploadFailureMessageKey,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../../lib/create/uploadToServer";
|
||||
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
|
||||
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||
|
||||
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
} catch (err) {
|
||||
setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { memo } from "react";
|
||||
import Upload from "../../../../components/controls/Upload";
|
||||
import InputLabel from "../../../../components/type/InputLabel";
|
||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||
|
||||
function CustomMethodCardUploadBlockRowViewComponent({
|
||||
@@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
||||
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||
aria-label={uploadFileInputAriaLabel}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
|
||||
+4
-2
@@ -8,6 +8,7 @@ import {
|
||||
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
||||
@@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
try {
|
||||
const { url } = await onPersistCustomUploadFile(file);
|
||||
setUploadAssetUrl(url);
|
||||
} catch {
|
||||
setUploadFieldError(tUpload("errors.generic"));
|
||||
} catch (err) {
|
||||
setUploadFileName(undefined);
|
||||
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||
} finally {
|
||||
setUploadFieldBusy(false);
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||
import InputLabel from "../../../../components/type/InputLabel";
|
||||
import ApplicableScopeField from "../ApplicableScopeField";
|
||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
||||
|
||||
const TEXT_PLACEHOLDER_MAX = 8000;
|
||||
@@ -119,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||
aria-label={copy.upload.uploadFileInputAriaLabel}
|
||||
onChange={onFileChosen}
|
||||
/>
|
||||
|
||||
@@ -71,8 +71,9 @@ export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||
export function getCreateFlowContentMaxClass(options: {
|
||||
step: CreateFlowStep | null | undefined;
|
||||
isTemplateReview?: boolean;
|
||||
isTemplatesPicker?: boolean;
|
||||
}): string {
|
||||
if (options.isTemplateReview) {
|
||||
if (options.isTemplateReview || options.isTemplatesPicker) {
|
||||
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
|
||||
}
|
||||
if (!options.step) {
|
||||
|
||||
@@ -43,7 +43,8 @@ const blurActiveElement = (): void => {
|
||||
*
|
||||
* 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 `/`.
|
||||
* users who came from `/create/review` via `/create/templates` return there
|
||||
* instead of `/`.
|
||||
*/
|
||||
export function useCreateFlowNavigation(
|
||||
options?: CreateFlowNavigationOptions,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Rule from "../../../../components/cards/Rule";
|
||||
import Button from "../../../../components/buttons/Button";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
vectorMarkPath,
|
||||
} from "../../../../../lib/assetUtils";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
@@ -25,6 +26,15 @@ export function CommunityReviewScreen() {
|
||||
const lgUp = useCreateFlowLgUp();
|
||||
const t = useTranslation("create.community.review");
|
||||
const { state, updateState } = useCreateFlow();
|
||||
/**
|
||||
* Server layout has an empty context; the client layout may already hold a
|
||||
* named draft. Defer empty-vs-congrats until after mount so the first paint
|
||||
* matches SSR (`null`) instead of swapping HeaderLockup titles.
|
||||
*/
|
||||
const [reviewReady, setReviewReady] = useState(false);
|
||||
useEffect(() => {
|
||||
setReviewReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* If the user picked **Customize** from a template before finishing community
|
||||
@@ -70,11 +80,10 @@ export function CommunityReviewScreen() {
|
||||
const cardTitle =
|
||||
typeof state.title === "string" && state.title.trim().length > 0
|
||||
? state.title.trim()
|
||||
: t("ruleCard.title");
|
||||
: "";
|
||||
/**
|
||||
* No placeholder fallback: if the user skipped `community-context`, leave
|
||||
* the card description off rather than render the old "Mutual Aid Monday
|
||||
* is a grassroots community…" sample, which read as real user copy.
|
||||
* the card description off rather than render sample copy as real user data.
|
||||
*/
|
||||
const cardDescription =
|
||||
typeof state.communityContext === "string" &&
|
||||
@@ -88,6 +97,36 @@ export function CommunityReviewScreen() {
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
|
||||
if (state.pendingTemplateAction || !reviewReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!cardTitle) {
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
/>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
href={CREATE_ROUTES.createFirstStep}
|
||||
>
|
||||
{t("empty.startLabel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
|
||||
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
||||
import { fetchAuthSession } from "../../../../../lib/create/api";
|
||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import {
|
||||
UploadToServerError,
|
||||
createFlowUploadFailureMessageKey,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
COMMUNITY_AVATAR_ACCEPT,
|
||||
messageKeyForCreateFlowUploadReason,
|
||||
validateCreateFlowUploadFile,
|
||||
} from "../../../../../lib/create/createFlowUploadValidation";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
storePendingCommunityAvatarFile,
|
||||
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
function hasCommunityAvatarUrl(url: string | undefined): boolean {
|
||||
return typeof url === "string" && url.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
|
||||
[localPreviewUrl],
|
||||
);
|
||||
|
||||
const resolveUploadError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof UploadToServerError) {
|
||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
||||
if (err.code === "server_misconfigured") {
|
||||
return tUpload("errors.misconfigured");
|
||||
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
|
||||
? state.communityAvatarUrl!.trim()
|
||||
: null;
|
||||
const serverAvatarUrlRef = useRef(serverAvatarUrl);
|
||||
serverAvatarUrlRef.current = serverAvatarUrl;
|
||||
|
||||
useEffect(() => {
|
||||
if (serverAvatarUrl) {
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}, [serverAvatarUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
const validated = await validateCreateFlowUploadFile(
|
||||
file,
|
||||
"communityAvatar",
|
||||
);
|
||||
if (validated.ok === false) {
|
||||
await clearPendingCommunityAvatarFile();
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (serverAvatarUrlRef.current) return;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return objectUrl;
|
||||
});
|
||||
} catch {
|
||||
// Missing IndexedDB / quota: leave the picker empty.
|
||||
}
|
||||
return tUpload("errors.generic");
|
||||
},
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveUploadError = useCallback(
|
||||
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
|
||||
[tUpload],
|
||||
);
|
||||
|
||||
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
|
||||
}
|
||||
|
||||
if (signedIn === false) {
|
||||
const validated = await validateCreateFlowUploadFile(
|
||||
file,
|
||||
"communityAvatar",
|
||||
);
|
||||
if (validated.ok === false) {
|
||||
setErrorMessage(
|
||||
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storePendingCommunityAvatarFile(file);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
) {
|
||||
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
|
||||
updateState({ communityAvatarUrl: undefined });
|
||||
}
|
||||
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
|
||||
void clearPendingCommunityAvatarFile();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||
|
||||
const displaySrc =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: localPreviewUrl;
|
||||
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
|
||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||
|
||||
return (
|
||||
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
accept={COMMUNITY_AVATAR_ACCEPT}
|
||||
aria-label={u.hintText}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
|
||||
import { buildTemplateReviewHref } from "../utils/flowSteps";
|
||||
|
||||
export function CreateFlowTemplatesPageClient({
|
||||
initialGridEntries,
|
||||
}: {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
}) {
|
||||
const t = useTranslation("pages.templates");
|
||||
const entries = useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
enableFacetRecommendations: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
justification="left"
|
||||
/>
|
||||
<GovernanceTemplateGrid
|
||||
entries={entries}
|
||||
hrefForTemplate={(slug) =>
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
|
||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
|
||||
import { CREATE_ROUTES } from "../utils/createFlowPaths";
|
||||
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
|
||||
|
||||
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
|
||||
title: messages.metadata.templates.title,
|
||||
});
|
||||
|
||||
/** In-flow template catalog after review “Create from template”. */
|
||||
export default async function CreateFlowTemplatesPage() {
|
||||
const rows = await listRuleTemplatesFromDb();
|
||||
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
||||
return <CreateFlowTemplatesPageClient initialGridEntries={initialGridEntries} />;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ export interface CreateFlowState {
|
||||
templateReviewBackSlug?: string;
|
||||
/**
|
||||
* True when the user opened `/create/review-template/{slug}` from the create
|
||||
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
|
||||
* wizard (`/create/templates` after `/create/review`). Persisted so Back
|
||||
* from template review targets `/create/review` and so returning from
|
||||
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,8 @@ export const CREATE_ROUTES = {
|
||||
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
||||
createFirstStep: `/create/${FIRST_STEP}`,
|
||||
review: "/create/review",
|
||||
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
|
||||
templatesPicker: "/create/templates",
|
||||
finalReview: "/create/final-review",
|
||||
completed: "/create/completed",
|
||||
editRule: "/create/edit-rule",
|
||||
|
||||
@@ -235,18 +235,34 @@ export function parseCreateFlowScreenFromPathname(
|
||||
): CreateFlowStep | null {
|
||||
if (!pathname || pathname.length === 0) return null;
|
||||
if (pathname.includes("/create/review-template/")) return null;
|
||||
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
|
||||
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const createIdx = parts.indexOf("create");
|
||||
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
||||
|
||||
const segment = parts[createIdx + 1];
|
||||
if (segment === "review-template") return null;
|
||||
if (segment === "review-template" || segment === "templates") return null;
|
||||
|
||||
return isValidStep(segment) ? segment : null;
|
||||
}
|
||||
|
||||
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */
|
||||
/**
|
||||
* `/create/templates` — in-flow catalog after review “Create from template”.
|
||||
* Not a wizard step; layout keeps Back / Save & Exit and does not use
|
||||
* marketing `/templates` chrome.
|
||||
*/
|
||||
export function isCreateFlowTemplatesPickerPath(
|
||||
pathname: string | null | undefined,
|
||||
): boolean {
|
||||
if (!pathname) return false;
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const createIdx = parts.indexOf("create");
|
||||
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
|
||||
return parts[createIdx + 1] === "templates";
|
||||
}
|
||||
|
||||
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
|
||||
|
||||
/**
|
||||
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||
* (draft preserved). Used by review “Create from template” and profile.
|
||||
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
||||
* (draft preserved). Used by profile “Create from template”.
|
||||
* - `recommendTemplates=1` — rank templates + “RECOMMENDED”
|
||||
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||
*/
|
||||
function TemplatesGridWithSearchParams({
|
||||
@@ -96,19 +96,18 @@ function TemplatesGrid({
|
||||
entries: TemplateGridCardEntry[];
|
||||
fromFlow: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<GovernanceTemplateGrid
|
||||
entries={entries}
|
||||
onTemplateClick={(slug) => {
|
||||
hrefForTemplate={(slug) =>
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
|
||||
}
|
||||
onTemplateClick={() => {
|
||||
if (!fromFlow) {
|
||||
// Marketing /templates has no session in hand; DELETE is
|
||||
// best-effort and the sentinel blocks stale-draft hydration.
|
||||
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||
}
|
||||
router.push(
|
||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
|
||||
};
|
||||
|
||||
/**
|
||||
* When `enableFacetRecommendations` (review → “Create from template” only),
|
||||
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
||||
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
||||
* When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
|
||||
* `/templates?recommendTemplates=1`), re-fetch ranked templates from
|
||||
* `GET /api/templates?facet.*` using the persisted create-flow draft.
|
||||
* Otherwise returns `initialGridEntries` from SSR.
|
||||
*/
|
||||
export function useTemplatesFacetGridEntries({
|
||||
initialGridEntries,
|
||||
|
||||
@@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow
|
||||
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
|
||||
import {
|
||||
CREATE_FLOW_UPLOAD_MAX_BYTES,
|
||||
maxBytesForPurpose,
|
||||
type CreateFlowUploadPurpose,
|
||||
} from "../../../lib/server/uploads/uploadConstants";
|
||||
import type { CreateFlowUploadValidationReason } from "../../../lib/create/createFlowUploadValidation";
|
||||
|
||||
function asUploadedBlob(value: FormDataEntryValue | null): Blob | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const candidate = value as Partial<Blob>;
|
||||
if (typeof candidate.arrayBuffer !== "function") return null;
|
||||
if (typeof candidate.size !== "number") return null;
|
||||
return value as Blob;
|
||||
}
|
||||
|
||||
function isPurpose(x: string): x is CreateFlowUploadPurpose {
|
||||
return x === "communityAvatar" || x === "customMethodAttachment";
|
||||
}
|
||||
|
||||
function messageForValidationReason(
|
||||
reason: CreateFlowUploadValidationReason,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case "empty":
|
||||
return "File is empty.";
|
||||
case "tooLarge":
|
||||
return "File exceeds the maximum allowed size for this upload purpose.";
|
||||
case "svg":
|
||||
return "SVG uploads are not allowed.";
|
||||
case "undecodable":
|
||||
return "File could not be decoded as a valid image.";
|
||||
case "invalidType":
|
||||
return "File type is not allowed for this upload purpose.";
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
@@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const purposeRaw = formData.get("purpose");
|
||||
const file = formData.get("file");
|
||||
const file = asUploadedBlob(formData.get("file"));
|
||||
|
||||
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
|
||||
return errorJson(
|
||||
@@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
if (!file) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"Missing `file` field (multipart file).",
|
||||
@@ -72,21 +99,29 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) {
|
||||
if (file.size === 0) {
|
||||
return errorJson("validation_error", messageForValidationReason("empty"), 400, {
|
||||
details: { reason: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
file.size > CREATE_FLOW_UPLOAD_MAX_BYTES ||
|
||||
file.size > maxBytesForPurpose(purposeRaw)
|
||||
) {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`,
|
||||
messageForValidationReason("tooLarge"),
|
||||
413,
|
||||
{ details: { reason: "tooLarge" } },
|
||||
);
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: purposeRaw,
|
||||
buffer: buf,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
if ("error" in saved) {
|
||||
@@ -95,10 +130,20 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
const reason = saved.reason ?? "invalidType";
|
||||
if (reason === "tooLarge") {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
messageForValidationReason("tooLarge"),
|
||||
413,
|
||||
{ details: { reason } },
|
||||
);
|
||||
}
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"File type or size is not allowed for this upload purpose.",
|
||||
messageForValidationReason(reason),
|
||||
400,
|
||||
{ details: { reason } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||
className = "",
|
||||
onClick,
|
||||
href,
|
||||
expanded = false,
|
||||
size: sizeProp,
|
||||
categories,
|
||||
@@ -95,8 +96,9 @@ const RuleContainer = memo<RuleProps>(
|
||||
icon={icon}
|
||||
backgroundColor={backgroundColor}
|
||||
className={className}
|
||||
href={hasBottomLinks ? undefined : href}
|
||||
onClick={hasBottomLinks ? undefined : handleClick}
|
||||
onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
|
||||
onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
|
||||
expanded={expanded}
|
||||
size={size}
|
||||
categories={categories}
|
||||
|
||||
@@ -50,6 +50,11 @@ export interface RuleProps {
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* When set, the card is a real link (focusable, Enter, open in a new tab)
|
||||
* instead of a click-only `role="button"` surface.
|
||||
*/
|
||||
href?: string;
|
||||
expanded?: boolean;
|
||||
size?: RuleSizeValue;
|
||||
categories?: Category[];
|
||||
@@ -93,6 +98,7 @@ export interface RuleViewProps {
|
||||
backgroundColor: string;
|
||||
className: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
expanded: boolean;
|
||||
size: RuleSizeValue;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import MultiSelect from "../../controls/MultiSelect";
|
||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||
import NavigationLink from "../../navigation/Link";
|
||||
@@ -19,6 +20,7 @@ export function RuleView({
|
||||
backgroundColor,
|
||||
className,
|
||||
onClick,
|
||||
href,
|
||||
onKeyDown,
|
||||
expanded,
|
||||
size,
|
||||
@@ -264,16 +266,11 @@ export function RuleView({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
|
||||
tabIndex={interactiveCard ? 0 : undefined}
|
||||
role={interactiveCard ? "button" : "article"}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={interactiveCard ? expanded : undefined}
|
||||
onClick={interactiveCard ? onClick : undefined}
|
||||
onKeyDown={interactiveCard ? onKeyDown : undefined}
|
||||
>
|
||||
const cardClassName = `${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${href ? "no-underline text-inherit" : ""} ${className || ""}`;
|
||||
const isLinkCard = Boolean(href) && interactiveCard;
|
||||
|
||||
const cardBody = (
|
||||
<>
|
||||
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
|
||||
<div
|
||||
className="
|
||||
@@ -483,6 +480,33 @@ export function RuleView({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLinkCard && href) {
|
||||
return (
|
||||
<NextLink
|
||||
href={href}
|
||||
className={cardClassName}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
>
|
||||
{cardBody}
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cardClassName}
|
||||
tabIndex={interactiveCard ? 0 : undefined}
|
||||
role={interactiveCard ? "button" : "article"}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={interactiveCard ? expanded : undefined}
|
||||
onClick={interactiveCard ? onClick : undefined}
|
||||
onKeyDown={interactiveCard ? onKeyDown : undefined}
|
||||
>
|
||||
{cardBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
let lastInteractedFocusable: HTMLElement | null = null;
|
||||
let interactionTrackingBound = false;
|
||||
|
||||
function closestFocusable(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const match = target.closest(FOCUSABLE_SELECTOR);
|
||||
return match instanceof HTMLElement ? match : null;
|
||||
}
|
||||
|
||||
/** Menu items unmount on select; restore to the control that opened the menu. */
|
||||
function menuTriggerFor(item: HTMLElement): HTMLElement | null {
|
||||
if (item.getAttribute("role") !== "menuitem") return null;
|
||||
const menu = item.closest("[role='menu']");
|
||||
const menuId = menu?.getAttribute("id");
|
||||
if (menuId) {
|
||||
const trigger = document.querySelector(
|
||||
`[aria-controls="${CSS.escape(menuId)}"]`,
|
||||
);
|
||||
if (trigger instanceof HTMLElement) return trigger;
|
||||
}
|
||||
const expanded = document.querySelector(
|
||||
'[aria-haspopup="menu"][aria-expanded="true"]',
|
||||
);
|
||||
return expanded instanceof HTMLElement ? expanded : null;
|
||||
}
|
||||
|
||||
function stableFocusableFrom(target: EventTarget | null): HTMLElement | null {
|
||||
const focusable = closestFocusable(target);
|
||||
if (!focusable) return null;
|
||||
return menuTriggerFor(focusable) ?? focusable;
|
||||
}
|
||||
|
||||
function isRestorable(
|
||||
node: HTMLElement | null,
|
||||
dialog: HTMLElement | null,
|
||||
): node is HTMLElement {
|
||||
if (!node?.isConnected) return false;
|
||||
if (node === document.body || node === document.documentElement) return false;
|
||||
if (dialog?.contains(node)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function retainLastInteracted(): void {
|
||||
if (interactionTrackingBound || typeof document === "undefined") return;
|
||||
interactionTrackingBound = true;
|
||||
const save = (event: Event) => {
|
||||
const el = stableFocusableFrom(event.target);
|
||||
if (!el) return;
|
||||
lastInteractedFocusable = el;
|
||||
};
|
||||
document.addEventListener("pointerdown", save, true);
|
||||
document.addEventListener("focusin", save);
|
||||
}
|
||||
|
||||
retainLastInteracted();
|
||||
|
||||
function snapshotTrigger(dialog: HTMLElement | null): HTMLElement | null {
|
||||
if (lastInteractedFocusable && !lastInteractedFocusable.isConnected) {
|
||||
lastInteractedFocusable = null;
|
||||
}
|
||||
const active =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const fromActive = isRestorable(active, dialog)
|
||||
? (menuTriggerFor(active) ?? active)
|
||||
: null;
|
||||
if (fromActive && isRestorable(fromActive, dialog)) return fromActive;
|
||||
if (isRestorable(lastInteractedFocusable, dialog)) {
|
||||
return lastInteractedFocusable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function restoreFocus(node: HTMLElement | null): void {
|
||||
if (!node?.isConnected) return;
|
||||
node.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
|
||||
* Escape-to-close, body scroll lock, focus move-in, tab trap, and restore
|
||||
* focus to the control that opened a Create-shell modal.
|
||||
*/
|
||||
export function useCreateModalA11y(
|
||||
isOpen: boolean,
|
||||
@@ -28,17 +111,17 @@ export function useCreateModalA11y(
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
previousActiveElementRef.current = document.activeElement as HTMLElement;
|
||||
previousActiveElementRef.current = snapshotTrigger(dialogRef.current);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
if (dialogRef.current) {
|
||||
const focusableElements = dialogRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
FOCUSABLE_SELECTOR,
|
||||
);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||
if (firstElement) {
|
||||
firstElement.focus();
|
||||
} else {
|
||||
@@ -51,23 +134,21 @@ export function useCreateModalA11y(
|
||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||
|
||||
const focusableElements = dialogRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
FOCUSABLE_SELECTOR,
|
||||
);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const firstElement = focusableElements[0] as HTMLElement | undefined;
|
||||
const lastElement = focusableElements[
|
||||
focusableElements.length - 1
|
||||
] as HTMLElement;
|
||||
] as HTMLElement | undefined;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
} else if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,7 +157,7 @@ export function useCreateModalA11y(
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleTab);
|
||||
previousActiveElementRef.current?.focus();
|
||||
restoreFocus(previousActiveElementRef.current);
|
||||
};
|
||||
}, [dialogRef, isOpen]);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/g
|
||||
|
||||
export interface GovernanceTemplateGridProps {
|
||||
entries: GovernanceTemplateCatalogEntry[];
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
/**
|
||||
* Real navigation target per card. Cards render as anchors so they are
|
||||
* keyboard-focusable and work in a new tab.
|
||||
*/
|
||||
hrefForTemplate: (_slug: string) => string;
|
||||
/** Optional side effects on activate (analytics, draft reset). */
|
||||
onTemplateClick?: (_slug: string) => void;
|
||||
/**
|
||||
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
|
||||
* Default keeps the template shell break at **768px**.
|
||||
@@ -19,6 +25,7 @@ export interface GovernanceTemplateGridProps {
|
||||
|
||||
export function GovernanceTemplateGrid({
|
||||
entries,
|
||||
hrefForTemplate,
|
||||
onTemplateClick,
|
||||
twoColumnsFromMd = false,
|
||||
}: GovernanceTemplateGridProps) {
|
||||
@@ -105,9 +112,14 @@ export function GovernanceTemplateGrid({
|
||||
/>
|
||||
}
|
||||
backgroundColor={card.backgroundColor}
|
||||
onClick={() => {
|
||||
onTemplateClick(card.slug);
|
||||
}}
|
||||
href={hrefForTemplate(card.slug)}
|
||||
onClick={
|
||||
onTemplateClick
|
||||
? () => {
|
||||
onTemplateClick(card.slug);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { buildTemplateReviewHref } from "../../../(app)/create/utils/flowSteps";
|
||||
import {
|
||||
fetchTemplates,
|
||||
isTemplatesFetchAborted,
|
||||
@@ -34,7 +34,6 @@ declare global {
|
||||
|
||||
const RuleStackContainer = memo<RuleStackProps>(
|
||||
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
|
||||
const router = useRouter();
|
||||
const namespace = translationNamespace ?? "pages.home.ruleStack";
|
||||
const t = useTranslation(namespace);
|
||||
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
||||
@@ -101,12 +100,12 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
// `signedIn: true` because this surface has no session in hand; DELETE is
|
||||
// best-effort and the sentinel blocks stale-draft hydration.
|
||||
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<RuleStackView
|
||||
className={className}
|
||||
hrefForTemplate={(slug) => buildTemplateReviewHref(slug)}
|
||||
onTemplateClick={handleTemplateClick}
|
||||
gridEntries={gridEntries}
|
||||
sectionTitle={t("title")}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface RuleStackProps {
|
||||
export interface RuleStackViewProps {
|
||||
className: string;
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
hrefForTemplate: (_slug: string) => string;
|
||||
/** `null` while loading curated templates from the API. */
|
||||
gridEntries: TemplateGridCardEntry[] | null;
|
||||
sectionTitle: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { RuleStackViewProps } from "./RuleStack.types";
|
||||
export function RuleStackView({
|
||||
className,
|
||||
onTemplateClick,
|
||||
hrefForTemplate,
|
||||
gridEntries,
|
||||
sectionTitle,
|
||||
sectionSubtitle,
|
||||
@@ -46,6 +47,7 @@ export function RuleStackView({
|
||||
) : (
|
||||
<GovernanceTemplateGrid
|
||||
entries={gridEntries}
|
||||
hrefForTemplate={hrefForTemplate}
|
||||
onTemplateClick={onTemplateClick}
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
|
||||
+4
-3
@@ -76,6 +76,7 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
|
||||
| `/create/templates` | In-flow template catalog after review **Create from template**. Same create chrome (Back, Save & Exit); cards link to `/create/review-template/[slug]?fromFlow=1`. Marketing `/templates` stays the public catalog. |
|
||||
|
||||
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
||||
|
||||
@@ -87,7 +88,7 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
|
||||
|
||||
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
|
||||
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
|
||||
- **Use without changes (in-flow)** — `/create/review` → `/templates?fromFlow=1` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
|
||||
- **Use without changes (in-flow)** — `/create/review` → `/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
|
||||
|
||||
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
|
||||
|
||||
@@ -97,9 +98,9 @@ The action for Customize is cleared on the first fire so later direct visits to
|
||||
| --- | --- | --- |
|
||||
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
||||
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
|
||||
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]?fromFlow=1` |
|
||||
| In-flow: `/create/review` footer "Create from template" → `/create/templates` → template click | no fresh-entry prep; cards are `?fromFlow=1` links | `/create/review-template/[slug]?fromFlow=1` |
|
||||
|
||||
Only one `?fromFlow=1` marker exists on `/templates`. Template clicks forward it onto `/create/review-template/[slug]?fromFlow=1` so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
|
||||
Only one `?fromFlow=1` marker exists on template-review URLs. In-flow catalog clicks on `/create/templates` always include it so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). Profile still uses marketing `/templates?fromFlow=1`. **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
|
||||
|
||||
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
|
||||
|
||||
/** Community avatar cap (bytes). */
|
||||
const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
|
||||
/** Custom-method attachment cap (bytes). */
|
||||
const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export const COMMUNITY_AVATAR_ACCEPT =
|
||||
"image/jpeg,image/png,image/webp,image/gif";
|
||||
export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`;
|
||||
|
||||
export type CreateFlowUploadValidationReason =
|
||||
| "empty"
|
||||
| "tooLarge"
|
||||
| "svg"
|
||||
| "invalidType"
|
||||
| "undecodable";
|
||||
|
||||
type SniffedUploadKind =
|
||||
| "jpeg"
|
||||
| "png"
|
||||
| "gif"
|
||||
| "webp"
|
||||
| "pdf"
|
||||
| "svg"
|
||||
| "empty"
|
||||
| "unknown";
|
||||
|
||||
type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp";
|
||||
|
||||
type CreateFlowUploadValidationOk = {
|
||||
ok: true;
|
||||
kind: CreateFlowUploadRasterKind | "pdf";
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
type CreateFlowUploadValidationFail = {
|
||||
ok: false;
|
||||
reason: CreateFlowUploadValidationReason;
|
||||
};
|
||||
|
||||
export type CreateFlowUploadValidationResult =
|
||||
| CreateFlowUploadValidationOk
|
||||
| CreateFlowUploadValidationFail;
|
||||
|
||||
export class CreateFlowUploadValidationError extends Error {
|
||||
readonly reason: CreateFlowUploadValidationReason;
|
||||
|
||||
constructor(reason: CreateFlowUploadValidationReason) {
|
||||
super(reason);
|
||||
this.name = "CreateFlowUploadValidationError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
const SVG_MIME = new Set(["image/svg+xml", "image/svg"]);
|
||||
|
||||
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
|
||||
return purpose === "communityAvatar"
|
||||
? COMMUNITY_AVATAR_MAX_BYTES
|
||||
: CUSTOM_ATTACHMENT_MAX_BYTES;
|
||||
}
|
||||
|
||||
function mimeTypeForSniffedKind(
|
||||
kind: CreateFlowUploadRasterKind | "pdf",
|
||||
): string {
|
||||
switch (kind) {
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
}
|
||||
}
|
||||
|
||||
export function messageKeyForCreateFlowUploadReason(
|
||||
reason: CreateFlowUploadValidationReason,
|
||||
): `errors.${CreateFlowUploadValidationReason}` {
|
||||
return `errors.${reason}`;
|
||||
}
|
||||
|
||||
function fileLooksLikeSvg(file: File): boolean {
|
||||
const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
if (SVG_MIME.has(declared)) return true;
|
||||
return /\.svgz?$/i.test(file.name);
|
||||
}
|
||||
|
||||
function readU16LE(bytes: Uint8Array, offset: number): number {
|
||||
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
||||
}
|
||||
|
||||
function readU16BE(bytes: Uint8Array, offset: number): number {
|
||||
return (bytes[offset]! << 8) | bytes[offset + 1]!;
|
||||
}
|
||||
|
||||
function readU24LE(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)
|
||||
);
|
||||
}
|
||||
|
||||
function readU32BE(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((bytes[offset]! << 24) |
|
||||
(bytes[offset + 1]! << 16) |
|
||||
(bytes[offset + 2]! << 8) |
|
||||
bytes[offset + 3]!) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean {
|
||||
if (offset + ascii.length > bytes.length) return false;
|
||||
for (let i = 0; i < ascii.length; i++) {
|
||||
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeSvgBytes(bytes: Uint8Array): boolean {
|
||||
let i = 0;
|
||||
if (
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xef &&
|
||||
bytes[1] === 0xbb &&
|
||||
bytes[2] === 0xbf
|
||||
) {
|
||||
i = 3;
|
||||
}
|
||||
while (
|
||||
i < bytes.length &&
|
||||
(bytes[i] === 0x20 ||
|
||||
bytes[i] === 0x09 ||
|
||||
bytes[i] === 0x0d ||
|
||||
bytes[i] === 0x0a)
|
||||
) {
|
||||
i += 1;
|
||||
}
|
||||
const head = new TextDecoder("utf-8", { fatal: false })
|
||||
.decode(bytes.subarray(i, Math.min(i + 512, bytes.length)))
|
||||
.toLowerCase();
|
||||
return head.includes("<svg") || head.includes("<!doctype svg");
|
||||
}
|
||||
|
||||
/**
|
||||
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
|
||||
* `<svg` substring so binary comments cannot be classified as SVG.
|
||||
*/
|
||||
function sniffCreateFlowUploadBytes(
|
||||
bytes: Uint8Array,
|
||||
): SniffedUploadKind {
|
||||
if (bytes.length === 0) return "empty";
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d &&
|
||||
bytes[5] === 0x0a &&
|
||||
bytes[6] === 0x1a &&
|
||||
bytes[7] === 0x0a
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) {
|
||||
return "gif";
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
startsWith(bytes, 0, "RIFF") &&
|
||||
startsWith(bytes, 8, "WEBP")
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) {
|
||||
return "pdf";
|
||||
}
|
||||
if (looksLikeSvgBytes(bytes)) return "svg";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function pngDimensions(
|
||||
bytes: Uint8Array,
|
||||
): { width: number; height: number } | null {
|
||||
if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null;
|
||||
const width = readU32BE(bytes, 16);
|
||||
const height = readU32BE(bytes, 20);
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function gifDimensions(
|
||||
bytes: Uint8Array,
|
||||
): { width: number; height: number } | null {
|
||||
if (bytes.length < 10) return null;
|
||||
const width = readU16LE(bytes, 6);
|
||||
const height = readU16LE(bytes, 8);
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function jpegDimensions(
|
||||
bytes: Uint8Array,
|
||||
): { width: number; height: number } | null {
|
||||
if (bytes.length < 4) return null;
|
||||
let offset = 2;
|
||||
while (offset + 8 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = bytes[offset + 1]!;
|
||||
if (marker === 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
if (offset + 3 >= bytes.length) return null;
|
||||
const size = readU16BE(bytes, offset + 2);
|
||||
if (size < 2) return null;
|
||||
const isSof =
|
||||
(marker >= 0xc0 && marker <= 0xc3) ||
|
||||
(marker >= 0xc5 && marker <= 0xc7) ||
|
||||
(marker >= 0xc9 && marker <= 0xcb) ||
|
||||
(marker >= 0xcd && marker <= 0xcf);
|
||||
if (isSof) {
|
||||
if (offset + 8 >= bytes.length) return null;
|
||||
const height = readU16BE(bytes, offset + 5);
|
||||
const width = readU16BE(bytes, offset + 7);
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
offset += 2 + size;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function webpDimensions(
|
||||
bytes: Uint8Array,
|
||||
): { width: number; height: number } | null {
|
||||
if (bytes.length < 20) return null;
|
||||
if (startsWith(bytes, 12, "VP8X")) {
|
||||
if (bytes.length < 30) return null;
|
||||
const width = readU24LE(bytes, 24) + 1;
|
||||
const height = readU24LE(bytes, 27) + 1;
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
if (startsWith(bytes, 12, "VP8L")) {
|
||||
if (bytes.length < 25 || bytes[20] !== 0x2f) return null;
|
||||
const bits =
|
||||
bytes[21]! |
|
||||
(bytes[22]! << 8) |
|
||||
(bytes[23]! << 16) |
|
||||
(bytes[24]! << 24);
|
||||
const width = (bits & 0x3fff) + 1;
|
||||
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
if (startsWith(bytes, 12, "VP8 ")) {
|
||||
if (bytes.length < 30) return null;
|
||||
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
|
||||
return null;
|
||||
}
|
||||
const width = readU16LE(bytes, 26) & 0x3fff;
|
||||
const height = readU16LE(bytes, 28) & 0x3fff;
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { width, height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rasterDimensions(
|
||||
kind: CreateFlowUploadRasterKind,
|
||||
bytes: Uint8Array,
|
||||
): { width: number; height: number } | null {
|
||||
switch (kind) {
|
||||
case "png":
|
||||
return pngDimensions(bytes);
|
||||
case "gif":
|
||||
return gifDimensions(bytes);
|
||||
case "jpeg":
|
||||
return jpegDimensions(bytes);
|
||||
case "webp":
|
||||
return webpDimensions(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
function purposeAllowsKind(
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
kind: CreateFlowUploadRasterKind | "pdf",
|
||||
): boolean {
|
||||
if (kind === "pdf") return purpose === "customMethodAttachment";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size, true-type, SVG, and header-decode checks shared by the browser and
|
||||
* `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client
|
||||
* so oversized files are rejected before they are read into memory.
|
||||
*/
|
||||
export function validateCreateFlowUploadBytes(
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
bytes: Uint8Array,
|
||||
): CreateFlowUploadValidationResult {
|
||||
if (bytes.length === 0) return { ok: false, reason: "empty" };
|
||||
if (bytes.length > maxBytesForPurpose(purpose)) {
|
||||
return { ok: false, reason: "tooLarge" };
|
||||
}
|
||||
|
||||
const sniffed = sniffCreateFlowUploadBytes(bytes);
|
||||
if (sniffed === "empty") return { ok: false, reason: "empty" };
|
||||
if (sniffed === "svg") return { ok: false, reason: "svg" };
|
||||
if (sniffed === "unknown") return { ok: false, reason: "invalidType" };
|
||||
if (!purposeAllowsKind(purpose, sniffed)) {
|
||||
return { ok: false, reason: "invalidType" };
|
||||
}
|
||||
if (sniffed === "pdf") {
|
||||
return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") };
|
||||
}
|
||||
|
||||
if (rasterDimensions(sniffed, bytes) == null) {
|
||||
return { ok: false, reason: "undecodable" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
kind: sniffed,
|
||||
mimeType: mimeTypeForSniffedKind(sniffed),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(reader.error ?? new Error("FileReader failed"));
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function decodeRasterInBrowser(
|
||||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
): Promise<boolean> {
|
||||
if (typeof createImageBitmap !== "function") return true;
|
||||
try {
|
||||
const copy = new Uint8Array(bytes);
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([copy], { type: mimeType }),
|
||||
);
|
||||
const ok = bitmap.width > 0 && bitmap.height > 0;
|
||||
bitmap.close();
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side validation: size and SVG name/type before reading bytes, then
|
||||
* the same sniff/decode path as the server, plus `createImageBitmap` when
|
||||
* available.
|
||||
*/
|
||||
export async function validateCreateFlowUploadFile(
|
||||
file: File,
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
): Promise<CreateFlowUploadValidationResult> {
|
||||
if (file.size === 0) return { ok: false, reason: "empty" };
|
||||
if (file.size > maxBytesForPurpose(purpose)) {
|
||||
return { ok: false, reason: "tooLarge" };
|
||||
}
|
||||
if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" };
|
||||
|
||||
const bytes = await readBlobBytes(file);
|
||||
const result = validateCreateFlowUploadBytes(purpose, bytes);
|
||||
if (result.ok === false || result.kind === "pdf") return result;
|
||||
|
||||
const decoded = await decodeRasterInBrowser(bytes, result.mimeType);
|
||||
if (!decoded) return { ok: false, reason: "undecodable" };
|
||||
return result;
|
||||
}
|
||||
@@ -22,7 +22,20 @@ function openDb(): Promise<IDBDatabase> {
|
||||
});
|
||||
}
|
||||
|
||||
function coerceToFile(value: unknown): File | null {
|
||||
if (value instanceof File) return value;
|
||||
if (typeof Blob !== "undefined" && value instanceof Blob) {
|
||||
return new File([value], "community-avatar", {
|
||||
type: value.type || "application/octet-stream",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
throw new Error("indexedDB is not available");
|
||||
}
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -38,6 +51,7 @@ export async function storePendingCommunityAvatarFile(file: File): Promise<void>
|
||||
|
||||
/** Read staged file without removing it (caller clears after successful upload). */
|
||||
export async function readPendingCommunityAvatarFile(): Promise<File | null> {
|
||||
if (typeof indexedDB === "undefined") return null;
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise<File | null>((resolve, reject) => {
|
||||
@@ -45,8 +59,7 @@ export async function readPendingCommunityAvatarFile(): Promise<File | null> {
|
||||
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
|
||||
const getReq = tx.objectStore(STORE).get(KEY);
|
||||
getReq.onsuccess = () => {
|
||||
const v = getReq.result;
|
||||
resolve(v instanceof File ? v : null);
|
||||
resolve(coerceToFile(getReq.result));
|
||||
};
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
|
||||
import {
|
||||
CreateFlowUploadValidationError,
|
||||
messageKeyForCreateFlowUploadReason,
|
||||
validateCreateFlowUploadFile,
|
||||
type CreateFlowUploadValidationReason,
|
||||
} from "./createFlowUploadValidation";
|
||||
|
||||
export type UploadToServerResult = {
|
||||
url: string;
|
||||
@@ -7,6 +13,20 @@ export type UploadToServerResult = {
|
||||
byteLength: number;
|
||||
};
|
||||
|
||||
const VALIDATION_REASONS = new Set<CreateFlowUploadValidationReason>([
|
||||
"empty",
|
||||
"tooLarge",
|
||||
"svg",
|
||||
"invalidType",
|
||||
"undecodable",
|
||||
]);
|
||||
|
||||
function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | null {
|
||||
return typeof value === "string" && VALIDATION_REASONS.has(value as CreateFlowUploadValidationReason)
|
||||
? (value as CreateFlowUploadValidationReason)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated multipart upload to `POST /api/uploads`.
|
||||
* Caller must have a session cookie (same-origin fetch).
|
||||
@@ -15,6 +35,11 @@ export async function uploadCreateFlowFile(
|
||||
file: File,
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
): Promise<UploadToServerResult> {
|
||||
const validated = await validateCreateFlowUploadFile(file, purpose);
|
||||
if (validated.ok === false) {
|
||||
throw new CreateFlowUploadValidationError(validated.reason);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("purpose", purpose);
|
||||
formData.append("file", file);
|
||||
@@ -36,14 +61,24 @@ export async function uploadCreateFlowFile(
|
||||
if (body && typeof body === "object" && "error" in body) {
|
||||
const e = (body as {
|
||||
error?: { message?: string; code?: string };
|
||||
details?: { reason?: unknown };
|
||||
}).error;
|
||||
if (!e) return { message: null as string | null, code: null as string | null };
|
||||
const details = (body as { details?: { reason?: unknown } }).details;
|
||||
const reason = reasonFromUnknown(details?.reason);
|
||||
if (!e) {
|
||||
return {
|
||||
message: null as string | null,
|
||||
code: null as string | null,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: typeof e.message === "string" ? e.message : null,
|
||||
code: typeof e.code === "string" ? e.code : null,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
return { message: null, code: null };
|
||||
return { message: null, code: null, reason: null };
|
||||
})();
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -54,7 +89,7 @@ export async function uploadCreateFlowFile(
|
||||
? "UNAUTHORIZED"
|
||||
: "UPLOAD_FAILED";
|
||||
const code = errParts.code ?? errParts.message ?? fallback;
|
||||
throw new UploadToServerError(res.status, code);
|
||||
throw new UploadToServerError(res.status, code, errParts.reason);
|
||||
}
|
||||
|
||||
const data = body as {
|
||||
@@ -83,11 +118,34 @@ export async function uploadCreateFlowFile(
|
||||
export class UploadToServerError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly reason: CreateFlowUploadValidationReason | null;
|
||||
|
||||
constructor(status: number, code: string) {
|
||||
constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
reason: CreateFlowUploadValidationReason | null = null,
|
||||
) {
|
||||
super(code);
|
||||
this.name = "UploadToServerError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function createFlowUploadFailureMessageKey(err: unknown): string {
|
||||
if (err instanceof CreateFlowUploadValidationError) {
|
||||
return messageKeyForCreateFlowUploadReason(err.reason);
|
||||
}
|
||||
if (err instanceof UploadToServerError) {
|
||||
if (err.reason) return messageKeyForCreateFlowUploadReason(err.reason);
|
||||
if (err.status === 413) return "errors.tooLarge";
|
||||
if (err.status === 401) return "errors.unauthorized";
|
||||
if (err.code === "server_misconfigured") {
|
||||
return "errors.misconfigured";
|
||||
}
|
||||
}
|
||||
return "errors.generic";
|
||||
}
|
||||
|
||||
export { CreateFlowUploadValidationError };
|
||||
|
||||
@@ -2,12 +2,12 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CreateFlowUploadPurpose } from "./uploadConstants";
|
||||
import {
|
||||
extensionForMime,
|
||||
isAllowedMime,
|
||||
maxBytesForPurpose,
|
||||
} from "./uploadConstants";
|
||||
import { extensionForMime } from "./uploadConstants";
|
||||
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
|
||||
import {
|
||||
validateCreateFlowUploadBytes,
|
||||
type CreateFlowUploadValidationReason,
|
||||
} from "../../create/createFlowUploadValidation";
|
||||
|
||||
export type SaveCreateFlowUploadResult = {
|
||||
/** Filename stem (UUID) without extension — used in GET URL. */
|
||||
@@ -18,30 +18,32 @@ export type SaveCreateFlowUploadResult = {
|
||||
byteLength: number;
|
||||
};
|
||||
|
||||
export type SaveCreateFlowUploadFailure = {
|
||||
error: "misconfigured" | "validation";
|
||||
reason?: CreateFlowUploadValidationReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path.
|
||||
* Trusts sniffed bytes, not the client-declared MIME type.
|
||||
*/
|
||||
export async function saveCreateFlowUpload(params: {
|
||||
purpose: CreateFlowUploadPurpose;
|
||||
buffer: Buffer;
|
||||
/** Declared MIME from the client `File.type` (validated server-side). */
|
||||
mimeType: string;
|
||||
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
|
||||
}): Promise<SaveCreateFlowUploadResult | SaveCreateFlowUploadFailure> {
|
||||
const root = getUploadRootFromEnv();
|
||||
if (!root) {
|
||||
return { error: "misconfigured" };
|
||||
}
|
||||
|
||||
const { purpose, buffer, mimeType } = params;
|
||||
if (buffer.length > maxBytesForPurpose(purpose)) {
|
||||
return { error: "validation" };
|
||||
}
|
||||
if (!isAllowedMime(purpose, mimeType)) {
|
||||
return { error: "validation" };
|
||||
const { purpose, buffer } = params;
|
||||
const validated = validateCreateFlowUploadBytes(purpose, buffer);
|
||||
if (validated.ok === false) {
|
||||
return { error: "validation", reason: validated.reason };
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const ext = extensionForMime(mimeType);
|
||||
const ext = extensionForMime(validated.mimeType);
|
||||
const fileName = `${id}${ext}`;
|
||||
const absolutePath = path.join(root, fileName);
|
||||
|
||||
@@ -51,7 +53,7 @@ export async function saveCreateFlowUpload(params: {
|
||||
return {
|
||||
id,
|
||||
urlPath: `/api/uploads/${id}`,
|
||||
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream",
|
||||
mimeType: validated.mimeType,
|
||||
byteLength: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
|
||||
|
||||
export type { CreateFlowUploadPurpose };
|
||||
export type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
|
||||
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
|
||||
export { maxBytesForPurpose } from "../../create/createFlowUploadValidation";
|
||||
|
||||
/** Max body size for multipart upload (bytes). */
|
||||
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const COMMUNITY_MAX = 5 * 1024 * 1024;
|
||||
const CUSTOM_MAX = 10 * 1024 * 1024;
|
||||
|
||||
const IMAGE_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]);
|
||||
|
||||
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
|
||||
return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX;
|
||||
}
|
||||
|
||||
export function isAllowedMime(
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
mime: string,
|
||||
): boolean {
|
||||
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
if (IMAGE_MIMES.has(m)) return true;
|
||||
if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Extension including dot, from normalized mime (lowercase). */
|
||||
export function extensionForMime(mime: string): string {
|
||||
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"title": "Your community is added - congrats!",
|
||||
"description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "Mutual Aid Mondays"
|
||||
"empty": {
|
||||
"title": "No community in progress yet",
|
||||
"description": "This step is for a community you have already named. Start from the beginning to add one.",
|
||||
"startLabel": "Start creating"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"errors": {
|
||||
"generic": "Something went wrong while uploading. Try again.",
|
||||
"tooLarge": "That file is too large. Try a smaller image or PDF.",
|
||||
"tooLarge": "That file is too large. Community photos can be up to 5 MB; attachments up to 10 MB.",
|
||||
"unauthorized": "Sign in to upload files. Use Save progress if you started without an account.",
|
||||
"misconfigured": "Uploads are not available on this server yet."
|
||||
"misconfigured": "Uploads are not available on this server yet.",
|
||||
"empty": "That file is empty. Choose a file that has content.",
|
||||
"svg": "SVG files aren't supported. Export a JPEG, PNG, WebP, or GIF instead.",
|
||||
"invalidType": "That file isn't a supported image (or PDF for attachments). Renaming the extension isn't enough.",
|
||||
"undecodable": "We couldn't read that image. It may be damaged — try another file."
|
||||
},
|
||||
"uploading": "Uploading…"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export default {
|
||||
control: false,
|
||||
description: "Catalog entries to render as a 2-column grid of Rules",
|
||||
},
|
||||
hrefForTemplate: {
|
||||
control: false,
|
||||
description: "Builds the review URL for each template card (real <a> href)",
|
||||
},
|
||||
onTemplateClick: { action: "template-clicked" },
|
||||
},
|
||||
};
|
||||
@@ -19,11 +23,13 @@ export default {
|
||||
export const Default = {
|
||||
args: {
|
||||
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 4),
|
||||
hrefForTemplate: (slug) => `/create/review-template/${slug}`,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleEntry = {
|
||||
args: {
|
||||
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 1),
|
||||
hrefForTemplate: (slug) => `/create/review-template/${slug}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import Create from "../../app/components/modals/Create";
|
||||
@@ -8,6 +9,23 @@ import TextInput from "../../app/components/controls/TextInput";
|
||||
|
||||
type CreateProps = React.ComponentProps<typeof Create>;
|
||||
|
||||
function CreateOpenHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open create dialog
|
||||
</button>
|
||||
<Create
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="Test Create Dialog"
|
||||
description="Test description"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Create", () => {
|
||||
const defaultProps: CreateProps = {
|
||||
isOpen: true,
|
||||
@@ -233,6 +251,17 @@ describe("Create", () => {
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("restores focus to the control that opened it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CreateOpenHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "Open create dialog" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("traps focus within create dialog", async () => {
|
||||
renderWithProviders(
|
||||
<Create {...defaultProps}>
|
||||
|
||||
@@ -1,12 +1,84 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import Dialog from "../../app/components/modals/Dialog";
|
||||
|
||||
type Props = React.ComponentProps<typeof Dialog>;
|
||||
|
||||
const dialogCopy = {
|
||||
title: "Confirm action",
|
||||
description: "This cannot be undone.",
|
||||
};
|
||||
|
||||
function DialogOpenHarness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open dialog
|
||||
</button>
|
||||
<Dialog
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={dialogCopy.title}
|
||||
description={dialogCopy.description}
|
||||
footer={
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFromMenuHarness() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const menuId = "dialog-focus-restore-menu";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={menuId}
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
>
|
||||
More options
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div role="menu" id={menuId}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setDialogOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Dialog
|
||||
isOpen={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title={dialogCopy.title}
|
||||
description={dialogCopy.description}
|
||||
footer={
|
||||
<button type="button" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dialog", () => {
|
||||
const defaultProps: Props = {
|
||||
isOpen: true,
|
||||
@@ -57,4 +129,41 @@ describe("Dialog", () => {
|
||||
renderWithProviders(<Dialog {...defaultProps} />);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
});
|
||||
|
||||
it("restores focus to the control that opened it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DialogOpenHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "Open dialog" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("restores focus to the opener when the opening click did not focus it", () => {
|
||||
renderWithProviders(<DialogOpenHarness />);
|
||||
const trigger = screen.getByRole("button", { name: "Open dialog" });
|
||||
fireEvent.pointerDown(trigger);
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("restores focus to the menu trigger when a menu item opened the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DialogFromMenuHarness />);
|
||||
const menuTrigger = screen.getByRole("button", { name: "More options" });
|
||||
await user.click(menuTrigger);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Remove" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(menuTrigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
type Props = React.ComponentProps<typeof GovernanceTemplateGrid>;
|
||||
|
||||
@@ -13,10 +16,10 @@ const config: ComponentTestSuiteConfig<Props> = {
|
||||
name: "GovernanceTemplateGrid",
|
||||
props: {
|
||||
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2),
|
||||
onTemplateClick: vi.fn(),
|
||||
hrefForTemplate: (slug: string) => `/create/review-template/${slug}`,
|
||||
} as Props,
|
||||
requiredProps: ["entries", "onTemplateClick"],
|
||||
primaryRole: "button",
|
||||
requiredProps: ["entries", "hrefForTemplate"],
|
||||
primaryRole: "link",
|
||||
testCases: {
|
||||
renders: true,
|
||||
accessibility: true,
|
||||
@@ -25,4 +28,21 @@ const config: ComponentTestSuiteConfig<Props> = {
|
||||
|
||||
describe("GovernanceTemplateGrid", () => {
|
||||
componentTestSuite<Props>(config);
|
||||
|
||||
it("renders each catalog card as a link to template review", () => {
|
||||
const entries = GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2);
|
||||
render(
|
||||
<GovernanceTemplateGrid
|
||||
entries={entries}
|
||||
hrefForTemplate={(slug) => `/create/review-template/${slug}`}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
screen.getByRole("link", { name: new RegExp(entry.title, "i") }),
|
||||
).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
|
||||
}
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,18 @@ import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/Com
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
|
||||
function ReviewWithTitle({ title }: { title: string }) {
|
||||
const { state, updateState } = useCreateFlow();
|
||||
const seededRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
updateState({ title });
|
||||
}, [title, updateState]);
|
||||
if (state.title !== title) return null;
|
||||
return <CommunityReviewScreen />;
|
||||
}
|
||||
|
||||
describe("CommunityReviewScreen", () => {
|
||||
beforeEach(() => {
|
||||
testRouter.replace.mockReset();
|
||||
@@ -21,8 +33,24 @@ describe("CommunityReviewScreen", () => {
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HeaderLockup with expected title", () => {
|
||||
it("shows an empty state instead of a baked-in community name", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "No community in progress yet" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mutual Aid Mondays")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "Your community is added - congrats!",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Start creating" }),
|
||||
).toHaveAttribute("href", "/create/informational");
|
||||
});
|
||||
|
||||
it("renders HeaderLockup with expected title when a community name exists", () => {
|
||||
render(<ReviewWithTitle title="Garden Club" />);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Your community is added - congrats!",
|
||||
@@ -30,8 +58,8 @@ describe("CommunityReviewScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HeaderLockup with expected description", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
it("renders HeaderLockup with expected description when a community name exists", () => {
|
||||
render(<ReviewWithTitle title="Garden Club" />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i,
|
||||
@@ -39,13 +67,13 @@ describe("CommunityReviewScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Rule with title fallback when no community name is set", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
|
||||
it("renders Rule with the community name from state", () => {
|
||||
render(<ReviewWithTitle title="Garden Club" />);
|
||||
expect(screen.getByText("Garden Club")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Rule description when the user has not entered community context", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
render(<ReviewWithTitle title="Garden Club" />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/Mutual Aid Monday is a grassroots community in Denver/i,
|
||||
@@ -53,13 +81,13 @@ describe("CommunityReviewScreen", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Rule as a button (card is interactive)", () => {
|
||||
render(<CommunityReviewScreen />);
|
||||
it("renders Rule as a button when a community name exists", () => {
|
||||
render(<ReviewWithTitle title="Garden Club" />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
|
||||
).toBe(true);
|
||||
expect(buttons.some((el) => el.textContent?.includes("Garden Club"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
storePendingCommunityAvatarFile,
|
||||
} from "../../lib/create/pendingCommunityAvatarUpload";
|
||||
import { fetchAuthSession } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
|
||||
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
|
||||
storePendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
|
||||
clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/create/api", () => ({
|
||||
fetchAuthSession: vi.fn().mockResolvedValue({ user: null }),
|
||||
}));
|
||||
|
||||
const pngBytes = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
|
||||
const copy = messages.create.community.communityUpload;
|
||||
const uploadErrors = messages.create.upload.errors;
|
||||
|
||||
function fileInput(): HTMLInputElement {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("CommunityUploadScreen", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null);
|
||||
vi.mocked(storePendingCommunityAvatarFile).mockResolvedValue(undefined);
|
||||
vi.mocked(clearPendingCommunityAvatarFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fetchAuthSession).mockResolvedValue({ user: null });
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
value: vi.fn(() => "blob:pending-avatar"),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
});
|
||||
|
||||
it("renders HeaderLockup", () => {
|
||||
render(<CommunityUploadScreen />);
|
||||
expect(
|
||||
@@ -22,4 +84,76 @@ describe("CommunityUploadScreen", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores a pending IndexedDB photo and lets the user remove it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pending = new File([pngBytes], "avatar.png", { type: "image/png" });
|
||||
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
|
||||
|
||||
render(<CommunityUploadScreen />);
|
||||
|
||||
const preview = await screen.findByRole("img", { name: copy.previewAlt });
|
||||
expect(preview).toHaveAttribute("src", "blob:pending-avatar");
|
||||
|
||||
const remove = screen.getByRole("button", {
|
||||
name: copy.clearPendingUploadAriaLabel,
|
||||
});
|
||||
await user.click(remove);
|
||||
|
||||
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("img", { name: copy.previewAlt }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects an empty file with a clear error and does not stage it", async () => {
|
||||
render(<CommunityUploadScreen />);
|
||||
await screen.findByText(copy.signInToUploadNote);
|
||||
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File([], "empty.png", { type: "image/png" })] },
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
uploadErrors.empty,
|
||||
);
|
||||
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a text file renamed as PNG", async () => {
|
||||
render(<CommunityUploadScreen />);
|
||||
await screen.findByText(copy.signInToUploadNote);
|
||||
|
||||
fireEvent.change(fileInput(), {
|
||||
target: {
|
||||
files: [new File(["hello"], "photo.png", { type: "image/png" })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
uploadErrors.invalidType,
|
||||
);
|
||||
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SVG uploads", async () => {
|
||||
render(<CommunityUploadScreen />);
|
||||
await screen.findByText(copy.signInToUploadNote);
|
||||
|
||||
fireEvent.change(fileInput(), {
|
||||
target: {
|
||||
files: [
|
||||
new File(
|
||||
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(uploadErrors.svg);
|
||||
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
} from "../utils/test-utils";
|
||||
import { CreateFlowTemplatesPageClient } from "../../app/(app)/create/templates/CreateFlowTemplatesPageClient";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
describe("Create flow templates picker", () => {
|
||||
test("renders catalog cards as in-flow template review links", () => {
|
||||
render(
|
||||
<CreateFlowTemplatesPageClient
|
||||
initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Templates", level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Consensus/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/create/review-template/consensus?fromFlow=1",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Solidarity Network/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/create/review-template/solidarity-network?fromFlow=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -54,30 +54,18 @@ describe("Templates page (/templates)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("each template card navigates to review flow for its slug", async () => {
|
||||
const user = userEvent.setup();
|
||||
test("each template card is a link to review flow for its slug", () => {
|
||||
render(
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
|
||||
testRouter.push.mockClear();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Solidarity Network/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/solidarity-network",
|
||||
);
|
||||
});
|
||||
for (const entry of GOVERNANCE_TEMPLATE_CATALOG) {
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: `Learn more about ${entry.title} governance pattern`,
|
||||
}),
|
||||
).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => {
|
||||
@@ -88,7 +76,7 @@ describe("Templates page (/templates)", () => {
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
screen.getByRole("link", { name: /Consensus/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -96,9 +84,6 @@ describe("Templates page (/templates)", () => {
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +98,7 @@ describe("Templates page (/templates)", () => {
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
screen.getByRole("link", { name: /Consensus/i }),
|
||||
);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe(
|
||||
@@ -124,12 +109,11 @@ describe("Templates page (/templates)", () => {
|
||||
).toBe(
|
||||
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
|
||||
);
|
||||
// In-flow picks also pass `?fromFlow=1` on the template review URL so
|
||||
// footer Back on `/create/review-template/…` returns to `/create/review`.
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus?fromFlow=1",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Consensus/i }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/create/review-template/consensus?fromFlow=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,24 @@ describe("Rule Component", () => {
|
||||
expect(handleClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders as a link when href is set", () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Rule
|
||||
{...defaultProps}
|
||||
href="/create/review-template/consensus"
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole("link", {
|
||||
name: "Learn more about Test Rule governance pattern",
|
||||
});
|
||||
expect(card).toHaveAttribute("href", "/create/review-template/consensus");
|
||||
fireEvent.click(card, { preventDefault() {} });
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies hover effects correctly", () => {
|
||||
render(<Rule {...defaultProps} />);
|
||||
|
||||
|
||||
@@ -226,13 +226,14 @@ describe("RuleStack Component", () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
const consensusCard = screen.getByRole("link", { name: /Consensus/i });
|
||||
expect(consensusCard).toHaveAttribute(
|
||||
"href",
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith("consensus template clicked");
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
@@ -251,7 +252,7 @@ describe("RuleStack Component", () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
const consensusCard = screen.getByRole("link", { name: /Consensus/i });
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
@@ -319,8 +320,10 @@ describe("RuleStack Component", () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const buttons = document.querySelectorAll('[role="button"]');
|
||||
const templateSurfaces = [...buttons].filter((el) =>
|
||||
const cards = screen.getAllByRole("link").filter((el) =>
|
||||
el.getAttribute("href")?.includes("/create/review-template/"),
|
||||
);
|
||||
const templateSurfaces = [...cards].filter((el) =>
|
||||
el.className.includes("--color-surface-invert"),
|
||||
);
|
||||
expect(templateSurfaces.length).toBe(homeFeatured.length);
|
||||
@@ -375,7 +378,7 @@ describe("RuleStack Component", () => {
|
||||
render(<RuleStack />);
|
||||
await waitForRuleStackCards();
|
||||
|
||||
const doOcracyCard = screen.getByText("Do-ocracy").closest("div");
|
||||
const doOcracyCard = screen.getByRole("link", { name: /Do-ocracy/i });
|
||||
await user.click(doOcracyCard);
|
||||
|
||||
expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", {
|
||||
|
||||
@@ -60,5 +60,11 @@ describe("createFlowLayoutTokens", () => {
|
||||
isTemplateReview: true,
|
||||
}),
|
||||
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
|
||||
expect(
|
||||
getCreateFlowContentMaxClass({
|
||||
step: null,
|
||||
isTemplatesPicker: true,
|
||||
}),
|
||||
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("createFlowPaths (CR-92 §2)", () => {
|
||||
|
||||
it("CREATE_ROUTES constants", () => {
|
||||
expect(CREATE_ROUTES.review).toBe("/create/review");
|
||||
expect(CREATE_ROUTES.templatesPicker).toBe("/create/templates");
|
||||
expect(CREATE_ROUTES.completed).toBe("/create/completed");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extensionForMime,
|
||||
isAllowedMime,
|
||||
isValidUploadFileId,
|
||||
maxBytesForPurpose,
|
||||
} from "../../lib/server/uploads/uploadConstants";
|
||||
@@ -12,18 +11,6 @@ describe("createFlow upload constants", () => {
|
||||
expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows images for both purposes", () => {
|
||||
expect(isAllowedMime("communityAvatar", "image/png")).toBe(true);
|
||||
expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows pdf only for customMethodAttachment", () => {
|
||||
expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false);
|
||||
expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("extensionForMime maps common types", () => {
|
||||
expect(extensionForMime("image/png")).toBe(".png");
|
||||
expect(extensionForMime("image/jpeg")).toBe(".jpg");
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CreateFlowUploadValidationError,
|
||||
maxBytesForPurpose,
|
||||
validateCreateFlowUploadBytes,
|
||||
validateCreateFlowUploadFile,
|
||||
} from "../../lib/create/createFlowUploadValidation";
|
||||
import { uploadCreateFlowFile } from "../../lib/create/uploadToServer";
|
||||
|
||||
/** 1×1 PNG (valid IHDR). */
|
||||
const PNG_1X1 = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
|
||||
/** SOF0 1×1 JPEG (header-decodable; not necessarily a complete scan). */
|
||||
const JPEG_1X1_SOF = Uint8Array.from([
|
||||
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
|
||||
0x11, 0x00, 0xff, 0xd9,
|
||||
]);
|
||||
|
||||
const GIF_1X1 = Uint8Array.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x3b,
|
||||
]);
|
||||
|
||||
/** VP8X 1×1 WebP. */
|
||||
const WEBP_1X1 = Uint8Array.from([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56,
|
||||
0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
const PDF_STUB = new TextEncoder().encode("%PDF-1.4\n%%EOF\n");
|
||||
|
||||
const SVG_MARKUP = new TextEncoder().encode(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
|
||||
);
|
||||
|
||||
const XML_SVG = new TextEncoder().encode(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
);
|
||||
|
||||
describe("validateCreateFlowUploadBytes", () => {
|
||||
it("accepts a real PNG as a community avatar", () => {
|
||||
const result = validateCreateFlowUploadBytes("communityAvatar", PNG_1X1);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
kind: "png",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts JPEG, GIF, and WebP headers", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", JPEG_1X1_SOF).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", GIF_1X1).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", WEBP_1X1).ok).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty buffer", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", new Uint8Array())).toEqual(
|
||||
{ ok: false, reason: "empty" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a file larger than the purpose cap", () => {
|
||||
const oversized = new Uint8Array(maxBytesForPurpose("communityAvatar") + 1);
|
||||
oversized.set(PNG_1X1.subarray(0, 8), 0);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", oversized)).toEqual({
|
||||
ok: false,
|
||||
reason: "tooLarge",
|
||||
});
|
||||
expect(maxBytesForPurpose("customMethodAttachment")).toBeGreaterThan(
|
||||
maxBytesForPurpose("communityAvatar"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects text spoofed as PNG", () => {
|
||||
expect(
|
||||
validateCreateFlowUploadBytes(
|
||||
"communityAvatar",
|
||||
new TextEncoder().encode("hello world"),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "invalidType" });
|
||||
});
|
||||
|
||||
it("rejects SVG markup, including xml-prefixed SVG", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", SVG_MARKUP)).toEqual(
|
||||
{ ok: false, reason: "svg" },
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("customMethodAttachment", XML_SVG)).toEqual(
|
||||
{ ok: false, reason: "svg" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a PNG signature that cannot be decoded", () => {
|
||||
const truncated = PNG_1X1.subarray(0, 8);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", truncated)).toEqual({
|
||||
ok: false,
|
||||
reason: "undecodable",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects PDF for community avatars and allows it for attachments", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", PDF_STUB)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalidType",
|
||||
});
|
||||
expect(validateCreateFlowUploadBytes("customMethodAttachment", PDF_STUB)).toEqual(
|
||||
{
|
||||
ok: true,
|
||||
kind: "pdf",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCreateFlowUploadFile", () => {
|
||||
it("rejects empty files before reading bytes", async () => {
|
||||
const file = new File([], "empty.png", { type: "image/png" });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "empty" });
|
||||
});
|
||||
|
||||
it("rejects SVG by name even when the MIME is spoofed", async () => {
|
||||
const file = new File([PNG_1X1], "logo.svg", { type: "image/png" });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "svg" });
|
||||
});
|
||||
|
||||
it("rejects a 17MB file by size without treating it as a type error", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "huge.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
Object.defineProperty(file, "size", { value: 17 * 1024 * 1024 });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "tooLarge" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadCreateFlowFile", () => {
|
||||
it("does not POST when client validation fails", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const file = new File(["not an image"], "photo.png", { type: "image/png" });
|
||||
await expect(
|
||||
uploadCreateFlowFile(file, "communityAvatar"),
|
||||
).rejects.toBeInstanceOf(CreateFlowUploadValidationError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,12 @@ import {
|
||||
isValidStep,
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
resolveCreateFlowBackTarget,
|
||||
shouldOfferCreateFlowSaveAndExit,
|
||||
isDirectTemplateReviewEntry,
|
||||
isCreateFlowTemplatesPickerPath,
|
||||
createFlowStepUsesSelectSplitScroll,
|
||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
describe("flowSteps", () => {
|
||||
@@ -158,10 +156,23 @@ describe("flowSteps", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("review Create from template uses fromFlow and recommendTemplates together", () => {
|
||||
it("isCreateFlowTemplatesPickerPath is true only for /create/templates", () => {
|
||||
expect(isCreateFlowTemplatesPickerPath("/create/templates")).toBe(true);
|
||||
expect(isCreateFlowTemplatesPickerPath("/create/templates/")).toBe(true);
|
||||
expect(isCreateFlowTemplatesPickerPath("/create/review-template/consensus")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCreateFlowTemplatesPickerPath("/templates")).toBe(false);
|
||||
expect(isCreateFlowTemplatesPickerPath("/create/review")).toBe(false);
|
||||
expect(isCreateFlowTemplatesPickerPath(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("parseCreateFlowScreenFromPathname ignores template auxiliary routes", () => {
|
||||
expect(parseCreateFlowScreenFromPathname("/create/review")).toBe("review");
|
||||
expect(parseCreateFlowScreenFromPathname("/create/templates")).toBeNull();
|
||||
expect(
|
||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||
).toBe("/templates?fromFlow=1&recommendTemplates=1");
|
||||
parseCreateFlowScreenFromPathname("/create/review-template/consensus"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mkdtemp, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
let uploadRoot: string | null = null;
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../lib/server/uploads/uploadRoot")
|
||||
>("../../lib/server/uploads/uploadRoot");
|
||||
return {
|
||||
...actual,
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
};
|
||||
});
|
||||
|
||||
import { saveCreateFlowUpload } from "../../lib/server/uploads/saveCreateFlowUpload";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
describe("saveCreateFlowUpload", () => {
|
||||
beforeEach(async () => {
|
||||
uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-save-"));
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockImplementation(() => uploadRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uploadRoot = null;
|
||||
});
|
||||
|
||||
it("writes a sniffed PNG even when the client declared the wrong MIME", async () => {
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: "communityAvatar",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
expect("error" in saved).toBe(false);
|
||||
if ("error" in saved) return;
|
||||
expect(saved.mimeType).toBe("image/png");
|
||||
const files = await readdir(uploadRoot!);
|
||||
expect(files).toEqual([`${saved.id}.png`]);
|
||||
});
|
||||
|
||||
it("rejects spoofed bytes without writing", async () => {
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: "communityAvatar",
|
||||
buffer: Buffer.from("not an image"),
|
||||
});
|
||||
expect(saved).toEqual({ error: "validation", reason: "invalidType" });
|
||||
expect(await readdir(uploadRoot!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ function multipartRequest(opts: {
|
||||
purpose?: string;
|
||||
fileName?: string;
|
||||
fileContent?: string;
|
||||
contentType?: string;
|
||||
}): NextRequest {
|
||||
const boundary = "----VitestBoundary";
|
||||
const parts: string[] = [];
|
||||
@@ -36,8 +37,9 @@ function multipartRequest(opts: {
|
||||
);
|
||||
}
|
||||
if (opts.fileName && opts.fileContent !== undefined) {
|
||||
const contentType = opts.contentType ?? "image/png";
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`,
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: ${contentType}\r\n\r\n${opts.fileContent}\r\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`--${boundary}--\r\n`);
|
||||
@@ -56,6 +58,7 @@ beforeEach(() => {
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
|
||||
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
|
||||
});
|
||||
|
||||
describe("POST /api/uploads", () => {
|
||||
@@ -78,7 +81,6 @@ describe("POST /api/uploads", () => {
|
||||
});
|
||||
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
@@ -90,7 +92,6 @@ describe("POST /api/uploads", () => {
|
||||
});
|
||||
|
||||
it("returns 400 when purpose is missing", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
const res = await POST(
|
||||
multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
|
||||
undefined,
|
||||
@@ -99,4 +100,56 @@ describe("POST /api/uploads", () => {
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("returns 400 for an empty file", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "empty.png",
|
||||
fileContent: "",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string };
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
expect(body.details?.reason).toBe("empty");
|
||||
});
|
||||
|
||||
it("returns 400 for SVG even when named .png", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "photo.png",
|
||||
fileContent: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
contentType: "image/png",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.details?.reason).toBe("svg");
|
||||
});
|
||||
|
||||
it("returns 400 for a text file spoofed as PNG", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "photo.png",
|
||||
fileContent: "not an image",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.details?.reason).toBe("invalidType");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user