Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4055278628 | ||
|
|
42d83c8b62 |
@@ -33,6 +33,7 @@ import {
|
|||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
|
||||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||||
CREATE_ROUTES,
|
CREATE_ROUTES,
|
||||||
@@ -482,12 +483,28 @@ function CreateFlowLayoutContent({
|
|||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
|
||||||
|
formEl?: HTMLFormElement | null,
|
||||||
|
) => {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
setCommunitySaveMagicLinkSuccess(false);
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
const raw = state.communitySaveEmail;
|
const namedEmail = formEl?.elements.namedItem("email");
|
||||||
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
const emailField =
|
||||||
if (!isValidCreateFlowSaveEmail(trimmed)) return;
|
namedEmail instanceof HTMLInputElement
|
||||||
|
? namedEmail
|
||||||
|
: typeof document === "undefined"
|
||||||
|
? null
|
||||||
|
: document.querySelector<HTMLInputElement>(
|
||||||
|
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
|
||||||
|
);
|
||||||
|
if (emailField != null && !emailField.checkValidity()) {
|
||||||
|
emailField.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw =
|
||||||
|
emailField != null ? emailField.value : state.communitySaveEmail;
|
||||||
|
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
|
||||||
setCommunitySaveMagicLinkSubmitting(true);
|
setCommunitySaveMagicLinkSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -770,6 +787,16 @@ function CreateFlowLayoutContent({
|
|||||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
|
{currentStep === "community-save" ? (
|
||||||
|
<form
|
||||||
|
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
|
className="hidden"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<main
|
<main
|
||||||
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
|
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
|
||||||
>
|
>
|
||||||
@@ -841,6 +868,8 @@ function CreateFlowLayoutContent({
|
|||||||
{footer.saveLater}
|
{footer.saveLater}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
@@ -851,9 +880,6 @@ function CreateFlowLayoutContent({
|
|||||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||||
}
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
|
||||||
void handleCommunitySaveMagicLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{communitySaveMagicLinkSubmitting
|
{communitySaveMagicLinkSubmitting
|
||||||
? footer.submitEmailSending
|
? footer.submitEmailSending
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
<TextArea
|
<TextArea
|
||||||
appearance="default"
|
appearance="default"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
label={copy.step2.title}
|
||||||
placeholder={copy.step2.fieldPlaceholder}
|
placeholder={copy.step2.fieldPlaceholder}
|
||||||
value={policyDescription}
|
value={policyDescription}
|
||||||
maxLength={maxDescriptionChars}
|
maxLength={maxDescriptionChars}
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useId } from "react";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||||
import TextArea from "../../../../components/controls/TextArea";
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
@@ -39,6 +39,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
onProportionBlockTitleChange,
|
onProportionBlockTitleChange,
|
||||||
onProportionDefaultChange,
|
onProportionDefaultChange,
|
||||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||||
|
const placeholderFieldId = useId();
|
||||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||||
|
|
||||||
@@ -53,10 +54,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
|
<label
|
||||||
|
htmlFor={placeholderFieldId}
|
||||||
|
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
|
||||||
|
>
|
||||||
{copy.text.placeholderLabel}
|
{copy.text.placeholderLabel}
|
||||||
</label>
|
</label>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id={placeholderFieldId}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
appearance="embedded"
|
appearance="embedded"
|
||||||
value={textPlaceholderBody}
|
value={textPlaceholderBody}
|
||||||
@@ -84,6 +89,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
label={copy.badges.blockTitleLabel}
|
||||||
placeholder={copy.badges.blockTitlePlaceholder}
|
placeholder={copy.badges.blockTitlePlaceholder}
|
||||||
value={badgeBlockTitle}
|
value={badgeBlockTitle}
|
||||||
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../components/modals/Create";
|
||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
import ContentLockup from "../../../components/type/ContentLockup";
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
|
|||||||
ariaLabel={tModal("title")}
|
ariaLabel={tModal("title")}
|
||||||
>
|
>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<TextInput
|
<TextArea
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
}}
|
}}
|
||||||
inputSize="medium"
|
size="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
|
|||||||
<TextInput
|
<TextInput
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
type="text"
|
||||||
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
|
|||||||
}}
|
}}
|
||||||
inputSize="medium"
|
inputSize="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
messageNamespace="create.community.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-structure":
|
case "community-structure":
|
||||||
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
stateField="communityContext"
|
stateField="communityContext"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
mainAlign="center"
|
mainAlign="center"
|
||||||
|
multiline
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-size":
|
case "community-size":
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import NumberedList from "../../../../components/type/NumberedList";
|
import NumberedList from "../../../../components/type/NumberedList";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
@@ -31,22 +30,6 @@ export function InformationalScreen() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const description: ReactNode = (
|
|
||||||
<>
|
|
||||||
{copy.descriptionLead}{" "}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copy.workshopLabel}
|
|
||||||
</a>{" "}
|
|
||||||
{copy.descriptionTrail}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -57,7 +40,7 @@ export function InformationalScreen() {
|
|||||||
>
|
>
|
||||||
<CreateFlowHeaderLockup
|
<CreateFlowHeaderLockup
|
||||||
title={copy.title}
|
title={copy.title}
|
||||||
description={description}
|
description={copy.description}
|
||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||||
import TextInput from "../../../../components/controls/TextInput";
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type CreateFlowContentTopBelowMd,
|
type CreateFlowContentTopBelowMd,
|
||||||
} from "../../components/CreateFlowStepShell";
|
} from "../../components/CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||||
|
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
|
||||||
import type { CreateFlowTextStateField } from "../../types";
|
import type { CreateFlowTextStateField } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,6 +23,10 @@ type Props = {
|
|||||||
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
||||||
mainAlign?: "start" | "center";
|
mainAlign?: "start" | "center";
|
||||||
inputType?: HTMLInputTypeAttribute;
|
inputType?: HTMLInputTypeAttribute;
|
||||||
|
/** Multi-line control (community description). */
|
||||||
|
multiline?: boolean;
|
||||||
|
/** Native `required` (community name and save-progress email). */
|
||||||
|
required?: boolean;
|
||||||
showCharacterCount?: boolean;
|
showCharacterCount?: boolean;
|
||||||
headerJustification?: HeaderLockupJustificationValue;
|
headerJustification?: HeaderLockupJustificationValue;
|
||||||
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
||||||
@@ -28,7 +34,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared narrow-column + TextInput pattern for Create Community text frames.
|
* Shared narrow-column + labelled field pattern for Create Community text frames.
|
||||||
*/
|
*/
|
||||||
export function CreateFlowTextFieldScreen({
|
export function CreateFlowTextFieldScreen({
|
||||||
messageNamespace,
|
messageNamespace,
|
||||||
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
|
|||||||
maxLength,
|
maxLength,
|
||||||
mainAlign = "start",
|
mainAlign = "start",
|
||||||
inputType = "text",
|
inputType = "text",
|
||||||
|
multiline = false,
|
||||||
|
required = false,
|
||||||
showCharacterCount = true,
|
showCharacterCount = true,
|
||||||
headerJustification = "left",
|
headerJustification = "left",
|
||||||
contentTopBelowMd = "space-1400",
|
contentTopBelowMd = "space-1400",
|
||||||
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
|
|||||||
const mainItems =
|
const mainItems =
|
||||||
mainAlign === "center" ? "items-center" : "items-start";
|
mainAlign === "center" ? "items-center" : "items-start";
|
||||||
|
|
||||||
|
const isEmail = inputType === "email";
|
||||||
|
const persistValue = (next: string) => {
|
||||||
|
setValue(next);
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({ [stateField]: next } as Record<string, string>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel = t("inputLabel");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
{multiline ? (
|
||||||
|
<TextArea
|
||||||
|
className="!transition-none"
|
||||||
|
label={fieldLabel}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
|
}}
|
||||||
|
size={mdUp ? "medium" : "small"}
|
||||||
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
textHint={hint}
|
||||||
|
maxLength={maxLength}
|
||||||
|
required={required}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type={inputType}
|
type={inputType}
|
||||||
|
name={isEmail ? "email" : undefined}
|
||||||
|
label={fieldLabel}
|
||||||
placeholder={t("placeholder")}
|
placeholder={t("placeholder")}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
const v = e.target.value;
|
onBlur={() => {
|
||||||
setValue(v);
|
const trimmed = isEmail
|
||||||
markCreateFlowInteraction();
|
? value.trim().toLowerCase()
|
||||||
updateState({ [stateField]: v } as Record<string, string>);
|
: value.trim();
|
||||||
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={
|
||||||
|
isEmail
|
||||||
|
? (e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
inputSize={mdUp ? "medium" : "small"}
|
inputSize={mdUp ? "medium" : "small"}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={hint}
|
textHint={hint}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
required={required || isEmail}
|
||||||
|
autoComplete={isEmail ? "email" : undefined}
|
||||||
|
inputMode={isEmail ? "email" : undefined}
|
||||||
|
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CreateFlowStepShell>
|
</CreateFlowStepShell>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
FIRST_STEP,
|
FIRST_STEP,
|
||||||
} from "./flowSteps";
|
} from "./flowSteps";
|
||||||
|
|
||||||
|
/** Associates the save-progress email field with the layout footer submit. */
|
||||||
|
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
|
||||||
|
"create-flow-community-save";
|
||||||
|
|
||||||
export const CREATE_ROUTES = {
|
export const CREATE_ROUTES = {
|
||||||
root: "/",
|
root: "/",
|
||||||
createRoot: "/create",
|
createRoot: "/create",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
import type { InputWithCounterProps } from "./InputWithCounter.types";
|
import type { InputWithCounterProps } from "./InputWithCounter.types";
|
||||||
|
|
||||||
export function InputWithCounterView({
|
export function InputWithCounterView({
|
||||||
@@ -10,12 +13,16 @@ export function InputWithCounterView({
|
|||||||
className = "",
|
className = "",
|
||||||
inputClassName = "",
|
inputClassName = "",
|
||||||
}: InputWithCounterProps) {
|
}: InputWithCounterProps) {
|
||||||
|
const inputId = useId();
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
|
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
|
||||||
{/* Label with help icon */}
|
{/* Label with help icon */}
|
||||||
{label && (
|
{label && (
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-002)]">
|
<div className="flex items-center gap-[var(--spacing-scale-002)]">
|
||||||
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{showHelpIcon && (
|
{showHelpIcon && (
|
||||||
@@ -52,6 +59,7 @@ export function InputWithCounterView({
|
|||||||
{/* Input field */}
|
{/* Input field */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
return (
|
const labelEl = label ? (
|
||||||
<div className={containerClasses}>
|
formHeader ? (
|
||||||
{formHeader && label && (
|
|
||||||
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
||||||
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
||||||
<label
|
<label
|
||||||
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<label id={labelId} htmlFor={textareaId} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{labelEl}
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
textHint = false,
|
textHint = false,
|
||||||
formHeader = true,
|
formHeader = true,
|
||||||
maxLength,
|
maxLength,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
|
required = false,
|
||||||
|
form,
|
||||||
|
onKeyDown,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
helpIconAlt={t("helpIconAlt")}
|
helpIconAlt={t("helpIconAlt")}
|
||||||
hintDefault={t("hintDefault")}
|
hintDefault={t("hintDefault")}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
|
required={required}
|
||||||
|
form={form}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
|
|||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
helpIconAlt: string;
|
helpIconAlt: string;
|
||||||
hintDefault: string;
|
hintDefault: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
|
required?: boolean;
|
||||||
|
form?: string;
|
||||||
|
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
disabled,
|
disabled,
|
||||||
error: _error,
|
error = false,
|
||||||
className: _className,
|
className: _className,
|
||||||
containerClasses,
|
containerClasses,
|
||||||
labelClasses,
|
labelClasses,
|
||||||
@@ -31,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
maxLength,
|
maxLength,
|
||||||
helpIconAlt,
|
helpIconAlt,
|
||||||
hintDefault,
|
hintDefault,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
|
required = false,
|
||||||
|
form,
|
||||||
|
onKeyDown,
|
||||||
|
state: _state,
|
||||||
|
isFilled: _isFilled,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
return (
|
const labelEl = label ? (
|
||||||
<div className={containerClasses}>
|
formHeader ? (
|
||||||
{formHeader && label && (
|
|
||||||
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
||||||
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
||||||
<label
|
<label
|
||||||
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<label id={labelId} htmlFor={inputId} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{labelEl}
|
||||||
<div className={inputWrapperClasses}>
|
<div className={inputWrapperClasses}>
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
<input
|
<input
|
||||||
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
|
required={required}
|
||||||
|
form={form}
|
||||||
|
aria-invalid={error || undefined}
|
||||||
|
aria-required={required || undefined}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
style={{ borderRadius }}
|
style={{ borderRadius }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
/** RFC 5321 max mailbox length (no angle brackets). */
|
/** RFC 5321 max mailbox length (no angle brackets). */
|
||||||
export const EMAIL_MAX_LEN = 254;
|
export const EMAIL_MAX_LEN = 254;
|
||||||
|
|
||||||
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
/**
|
||||||
|
* Fallback when `HTMLInputElement.checkValidity()` is unavailable (SSR).
|
||||||
|
* Matches the HTML living standard’s type=email pattern closely enough
|
||||||
|
* for the save-progress field; the live input is the source of truth in
|
||||||
|
* the browser.
|
||||||
|
*/
|
||||||
|
const EMAIL_FALLBACK_PATTERN =
|
||||||
|
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a create-flow save-progress email would pass a required
|
||||||
|
* `type="email"` input’s `checkValidity()`. Does not trim: native
|
||||||
|
* validity rejects leading or trailing spaces.
|
||||||
|
*/
|
||||||
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
||||||
if (typeof value !== "string") return false;
|
if (typeof value !== "string") return false;
|
||||||
const t = value.trim();
|
if (value.length === 0 || value.length > EMAIL_MAX_LEN) return false;
|
||||||
if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false;
|
// Native type=email rejects whitespace; jsdom’s checkValidity does not.
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
if (/\s/.test(value)) return false;
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return EMAIL_FALLBACK_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "email";
|
||||||
|
input.required = true;
|
||||||
|
input.value = value;
|
||||||
|
return input.checkValidity();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Why does your community exist?",
|
"title": "Why does your community exist?",
|
||||||
"description": "Edit or change the description to match how you’d like the organization to be described to other users. Try and describe your mission, goals, and scope.",
|
"description": "Optional. Write a short paragraph about your mission, goals, and scope. You can skip this and add it later.",
|
||||||
|
"inputLabel": "Community description",
|
||||||
"placeholder": "Describe your community",
|
"placeholder": "Describe your community",
|
||||||
"characterCountTemplate": "{current}/{max}"
|
"characterCountTemplate": "{current}/{max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "What is your community called?",
|
"title": "What is your community called?",
|
||||||
"description": "This will be the name of your community",
|
"description": "This will be the name of your community.",
|
||||||
|
"inputLabel": "Community name",
|
||||||
"placeholder": "Enter community name",
|
"placeholder": "Enter community name",
|
||||||
"characterCountTemplate": "{current}/{max}"
|
"characterCountTemplate": "{current}/{max}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Save your progress",
|
"title": "Save your progress",
|
||||||
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
||||||
|
"inputLabel": "Email address",
|
||||||
"placeholder": "email@domain.com",
|
"placeholder": "email@domain.com",
|
||||||
"characterCountTemplate": "{current}/{max}",
|
"characterCountTemplate": "{current}/{max}",
|
||||||
"magicLinkSuccessTitle": "Check your email to log in",
|
"magicLinkSuccessTitle": "Check your email to log in",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"header": {
|
"header": {
|
||||||
"title": "What kind of community would you like to improve?",
|
"title": "What kind of community would you like to improve?",
|
||||||
"description": "Choose tags the describe your community. You can also combine or add new values to the list."
|
"description": "Choose tags that describe your community. You can also combine or add new values to the list."
|
||||||
},
|
},
|
||||||
"organizationMultiSelect": {
|
"organizationMultiSelect": {
|
||||||
"label": "Organization Type",
|
"label": "Organization Type",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Add a photo to identify your group",
|
"title": "Add a photo to identify your group",
|
||||||
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
"description": "This photo will be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
||||||
"hintText": "Add image from your device",
|
"hintText": "Add image from your device",
|
||||||
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
|
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
|
||||||
"uploadingLabel": "Uploading your photo…",
|
"uploadingLabel": "Uploading your photo…",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "How CommunityRule helps groups like yours",
|
"title": "How CommunityRule helps groups like yours",
|
||||||
"descriptionLead": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a",
|
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider.",
|
||||||
"workshopLabel": "workshop",
|
|
||||||
"descriptionTrail": "that your group can use to go through the process it together.",
|
|
||||||
"steps": {
|
"steps": {
|
||||||
"0": {
|
"0": {
|
||||||
"title": "Tell us about your organization",
|
"title": "Tell us about your organization",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"title": "How do you want to describe your new policy?",
|
"title": "How do you want to describe your new policy?",
|
||||||
"description": "This description will show up with the title to offer additional context about what the policy is how it should be applied.",
|
"description": "This description will show up with the title to offer additional context about what the policy is and how it should be applied.",
|
||||||
"fieldPlaceholder": "Policy description"
|
"fieldPlaceholder": "Policy description"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
|
|||||||
@@ -11,5 +11,28 @@ export const Default = {
|
|||||||
messageNamespace: "create.community.communityName",
|
messageNamespace: "create.community.communityName",
|
||||||
stateField: "title",
|
stateField: "title",
|
||||||
maxLength: 48,
|
maxLength: 48,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommunityDescription = {
|
||||||
|
args: {
|
||||||
|
messageNamespace: "create.community.communityContext",
|
||||||
|
stateField: "communityContext",
|
||||||
|
maxLength: 200,
|
||||||
|
mainAlign: "center",
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommunitySave = {
|
||||||
|
args: {
|
||||||
|
messageNamespace: "create.community.communitySave",
|
||||||
|
stateField: "communitySaveEmail",
|
||||||
|
maxLength: 254,
|
||||||
|
mainAlign: "center",
|
||||||
|
inputType: "email",
|
||||||
|
showCharacterCount: false,
|
||||||
|
headerJustification: "center",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ describe("CommunityStructureSelectScreen", () => {
|
|||||||
expect(screen.getByText("Organization Type")).toBeInTheDocument();
|
expect(screen.getByText("Organization Type")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Scale")).toBeInTheDocument();
|
expect(screen.getByText("Scale")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Maturity")).toBeInTheDocument();
|
expect(screen.getByText("Maturity")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Choose tags that describe your community/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1044,8 +1044,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
|
|||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("rule-title-edit"));
|
fireEvent.click(await screen.findByTestId("rule-title-edit"));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(within(dialog).getByText(/Community name/i)).toBeInTheDocument();
|
const input = within(dialog).getByRole("textbox", {
|
||||||
const input = within(dialog).getByRole("textbox");
|
name: /Community name/i,
|
||||||
|
});
|
||||||
fireEvent.change(input, { target: { value: "Renamed Commons" } });
|
fireEvent.change(input, { target: { value: "Renamed Commons" } });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
@@ -1076,10 +1077,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
|
|||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("rule-description-edit"));
|
fireEvent.click(await screen.findByTestId("rule-description-edit"));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
expect(
|
const input = within(dialog).getByRole("textbox", {
|
||||||
within(dialog).getByText(/Community description/i),
|
name: /Community description/i,
|
||||||
).toBeInTheDocument();
|
});
|
||||||
const input = within(dialog).getByRole("textbox");
|
|
||||||
fireEvent.change(input, { target: { value: "Updated copy" } });
|
fireEvent.change(input, { target: { value: "Updated copy" } });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,9 @@ describe("InformationalScreen", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders workshop as a link (URL TBD) with underline per Figma", () => {
|
it("does not offer a workshop link until a destination exists", () => {
|
||||||
render(<InformationalScreen />);
|
render(<InformationalScreen />);
|
||||||
const workshop = screen.getByRole("link", { name: "workshop" });
|
expect(screen.queryByRole("link", { name: "workshop" })).not.toBeInTheDocument();
|
||||||
expect(workshop).toHaveAttribute("href", "#");
|
|
||||||
expect(workshop.className).toMatch(/underline/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders first numbered list item title", () => {
|
it("renders first numbered list item title", () => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { describe, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
} from "../utils/test-utils";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
import {
|
import {
|
||||||
componentTestSuite,
|
componentTestSuite,
|
||||||
type ComponentTestSuiteConfig,
|
type ComponentTestSuiteConfig,
|
||||||
@@ -28,4 +33,19 @@ const config: ComponentTestSuiteConfig<Props> = {
|
|||||||
|
|
||||||
describe("InputWithCounter", () => {
|
describe("InputWithCounter", () => {
|
||||||
componentTestSuite<Props>(config);
|
componentTestSuite<Props>(config);
|
||||||
|
|
||||||
|
it("associates the visible label with the input", () => {
|
||||||
|
render(
|
||||||
|
<InputWithCounter
|
||||||
|
label="Community name"
|
||||||
|
placeholder="Enter a name"
|
||||||
|
value=""
|
||||||
|
onChange={vi.fn()}
|
||||||
|
maxLength={50}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: "Community name" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ describe("TextArea appearance", () => {
|
|||||||
expect(textarea).toHaveClass("border-0");
|
expect(textarea).toHaveClass("border-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a programmatic label when the visible header is off", () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<TextArea label="Community description" formHeader={false} value="" />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: "Community description" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(container.querySelector("label")).toHaveClass("sr-only");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses tertiary text in the embedded default state and primary on focus", () => {
|
it("uses tertiary text in the embedded default state and primary on focus", () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<TextArea label="Notes" value="Some text" appearance="embedded" />,
|
<TextArea label="Notes" value="Some text" appearance="embedded" />,
|
||||||
|
|||||||
@@ -55,4 +55,30 @@ describe("TextInput (size tests)", () => {
|
|||||||
const input = container.querySelector("input");
|
const input = container.querySelector("input");
|
||||||
expect(input).toHaveAttribute("maxLength", "200");
|
expect(input).toHaveAttribute("maxLength", "200");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a programmatic label when the visible header is off", () => {
|
||||||
|
const { getByRole, container } = render(
|
||||||
|
<TextInput label="Community name" formHeader={false} />,
|
||||||
|
);
|
||||||
|
expect(getByRole("textbox", { name: "Community name" })).toBeInTheDocument();
|
||||||
|
expect(container.querySelector("label")).toHaveClass("sr-only");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards autocomplete, inputMode, required, and form", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextInput
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
required
|
||||||
|
form="create-flow-community-save"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).toHaveAttribute("autocomplete", "email");
|
||||||
|
expect(input).toHaveAttribute("inputmode", "email");
|
||||||
|
expect(input).toBeRequired();
|
||||||
|
expect(input).toHaveAttribute("form", "create-flow-community-save");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
|
import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
|
||||||
|
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../app/(app)/create/utils/createFlowPaths";
|
||||||
|
|
||||||
describe("CreateFlowTextFieldScreen (community name)", () => {
|
describe("CreateFlowTextFieldScreen (community name)", () => {
|
||||||
it("renders main heading", () => {
|
it("renders main heading", () => {
|
||||||
@@ -10,6 +11,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
|
|||||||
messageNamespace="create.community.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
|
required
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -19,19 +21,63 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders description and text field", () => {
|
it("renders description and a labelled text field", () => {
|
||||||
render(
|
render(
|
||||||
<CreateFlowTextFieldScreen
|
<CreateFlowTextFieldScreen
|
||||||
messageNamespace="create.community.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
|
required
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("This will be the name of your community"),
|
screen.getByText("This will be the name of your community."),
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByPlaceholderText("Enter community name"),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
const input = screen.getByRole("textbox", { name: "Community name" });
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveAttribute("placeholder", "Enter community name");
|
||||||
|
expect(input).toBeRequired();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateFlowTextFieldScreen (community description)", () => {
|
||||||
|
it("uses a textarea and treats the field as optional", () => {
|
||||||
|
render(
|
||||||
|
<CreateFlowTextFieldScreen
|
||||||
|
messageNamespace="create.community.communityContext"
|
||||||
|
stateField="communityContext"
|
||||||
|
maxLength={200}
|
||||||
|
multiline
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Optional\. Write a short paragraph/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const field = screen.getByRole("textbox", {
|
||||||
|
name: "Community description",
|
||||||
|
});
|
||||||
|
expect(field.tagName).toBe("TEXTAREA");
|
||||||
|
expect(field).not.toBeRequired();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateFlowTextFieldScreen (save progress email)", () => {
|
||||||
|
it("uses a labelled email field with native email attributes", () => {
|
||||||
|
render(
|
||||||
|
<CreateFlowTextFieldScreen
|
||||||
|
messageNamespace="create.community.communitySave"
|
||||||
|
stateField="communitySaveEmail"
|
||||||
|
maxLength={254}
|
||||||
|
inputType="email"
|
||||||
|
showCharacterCount={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByRole("textbox", { name: "Email address" });
|
||||||
|
expect(input).toHaveAttribute("type", "email");
|
||||||
|
expect(input).toHaveAttribute("autocomplete", "email");
|
||||||
|
expect(input).toHaveAttribute("inputmode", "email");
|
||||||
|
expect(input).toHaveAttribute("name", "email");
|
||||||
|
expect(input).toHaveAttribute("form", CREATE_FLOW_COMMUNITY_SAVE_FORM_ID);
|
||||||
|
expect(input).toBeRequired();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("CommunityUploadScreen", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/This photo be used as a profile picture for your group/i,
|
/This photo will be used as a profile picture for your group/i,
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
|
||||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||||
CREATE_ROUTES,
|
CREATE_ROUTES,
|
||||||
@@ -42,4 +43,10 @@ describe("createFlowPaths (CR-92 §2)", () => {
|
|||||||
expect(CREATE_ROUTES.review).toBe("/create/review");
|
expect(CREATE_ROUTES.review).toBe("/create/review");
|
||||||
expect(CREATE_ROUTES.completed).toBe("/create/completed");
|
expect(CREATE_ROUTES.completed).toBe("/create/completed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes a stable form id for the save-progress email step", () => {
|
||||||
|
expect(CREATE_FLOW_COMMUNITY_SAVE_FORM_ID).toBe(
|
||||||
|
"create-flow-community-save",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
|
|
||||||
|
describe("isValidCreateFlowSaveEmail", () => {
|
||||||
|
it("rejects empty, whitespace, and non-string values", () => {
|
||||||
|
expect(isValidCreateFlowSaveEmail("")).toBe(false);
|
||||||
|
expect(isValidCreateFlowSaveEmail(" ")).toBe(false);
|
||||||
|
expect(isValidCreateFlowSaveEmail(null)).toBe(false);
|
||||||
|
expect(isValidCreateFlowSaveEmail(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects values the native email control would reject", () => {
|
||||||
|
expect(isValidCreateFlowSaveEmail("not-an-email")).toBe(false);
|
||||||
|
expect(isValidCreateFlowSaveEmail("user@example.com ")).toBe(false);
|
||||||
|
expect(isValidCreateFlowSaveEmail(" user@example.com")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a required type=email value", () => {
|
||||||
|
expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user