Add button and custom modal flow implemented
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
} from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/**
|
||||
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
|
||||
* and writes `communityAvatarUrl` on success.
|
||||
*/
|
||||
export function CreateFlowPendingAvatarFlush({
|
||||
sessionUser,
|
||||
sessionResolved,
|
||||
}: {
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { updateState } = useCreateFlow();
|
||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionResolved || !sessionUser) return;
|
||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
if (cancelled) return;
|
||||
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.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, updateState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Chip from "../../../components/controls/Chip";
|
||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
@@ -20,6 +20,7 @@ import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import InputLabel from "../../../components/type/InputLabel";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
|
||||
const TEXT_VALUE_MAX = 8000;
|
||||
|
||||
@@ -55,7 +56,12 @@ function CustomMethodCardUploadBlockRow({
|
||||
noFileChosen: string;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
|
||||
const hasAsset = Boolean(block.assetUrl?.trim());
|
||||
const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -65,41 +71,81 @@ function CustomMethodCardUploadBlockRow({
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{displayName}
|
||||
</p>
|
||||
{!hasAsset ? (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{hasAsset ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL
|
||||
<img
|
||||
src={block.assetUrl!.trim()}
|
||||
alt={previewAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
||||
aria-label={uploadFileInputAriaLabel}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
const name = file?.name?.trim();
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? {
|
||||
...b,
|
||||
...(name ? { fileName: name } : {}),
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setErrorMessage(null);
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(
|
||||
file,
|
||||
"customMethodAttachment",
|
||||
);
|
||||
const name = file.name?.trim();
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? {
|
||||
...b,
|
||||
...(name ? { fileName: name } : {}),
|
||||
assetUrl: url,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Upload
|
||||
hintText={uploadHint}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
active={!busy}
|
||||
hintText={busy ? tUpload("uploading") : uploadHint}
|
||||
onClick={() => {
|
||||
if (!busy) uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
{block.fileName?.trim() ? (
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{block.fileName?.trim() || block.assetUrl?.trim() ? (
|
||||
<InlineTextButton
|
||||
onClick={() =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload" ? { ...b, fileName: undefined } : b,
|
||||
b.kind === "upload"
|
||||
? { ...b, fileName: undefined, assetUrl: undefined }
|
||||
: b,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -220,15 +266,30 @@ function CustomMethodCardFieldBlocksSummaryComponent({
|
||||
return (
|
||||
<div key={block.id}>
|
||||
{readOnly ? (
|
||||
<ModalTextAreaField
|
||||
label={block.blockTitle}
|
||||
rows={2}
|
||||
value={
|
||||
block.fileName?.trim() ? block.fileName : noFileChosen
|
||||
}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputLabel
|
||||
label={block.blockTitle}
|
||||
helpIcon
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
{block.assetUrl?.trim() ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={block.assetUrl.trim()}
|
||||
alt={
|
||||
block.fileName?.trim() ||
|
||||
block.blockTitle ||
|
||||
noFileChosen
|
||||
}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{noFileChosen}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CustomMethodCardUploadBlockRow
|
||||
block={block}
|
||||
|
||||
+76
-8
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import {
|
||||
useMessages,
|
||||
useTranslation,
|
||||
} from "../../../../contexts/MessagesContext";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
@@ -13,9 +16,10 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
|
||||
* `20066:14748`, `20094:48551`, `20066:14361`).
|
||||
*/
|
||||
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
({ isOpen, onClose, onFinalize }) => {
|
||||
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
|
||||
const m = useMessages();
|
||||
const t = useTranslation("common");
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const w = m.create.customRule.customMethodCardWizard;
|
||||
|
||||
const copy = useMemo(
|
||||
@@ -23,10 +27,23 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
step1: w.steps["1"],
|
||||
step2: w.steps["2"],
|
||||
step3: w.steps["3"],
|
||||
step3BlocksList: w.step3BlocksList,
|
||||
fieldTypeLabels: {
|
||||
text: w.addCustomField.fieldTypes.text,
|
||||
badges: w.addCustomField.fieldTypes.badges,
|
||||
upload: w.addCustomField.fieldTypes.upload,
|
||||
proportion: w.addCustomField.fieldTypes.proportion,
|
||||
},
|
||||
footerFinalize: w.footer.finalize,
|
||||
fieldModals: w.fieldModals,
|
||||
}),
|
||||
[w.fieldModals, w.footer.finalize, w.steps],
|
||||
[
|
||||
w.addCustomField.fieldTypes,
|
||||
w.fieldModals,
|
||||
w.footer.finalize,
|
||||
w.step3BlocksList,
|
||||
w.steps,
|
||||
],
|
||||
);
|
||||
|
||||
const fieldBodiesCopy = useMemo(
|
||||
@@ -58,6 +75,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
|
||||
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
|
||||
const [proportionDefault, setProportionDefault] = useState(50);
|
||||
|
||||
@@ -70,6 +94,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
setBadgeOptions([]);
|
||||
setUploadBlockTitle("");
|
||||
setUploadFileName(undefined);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldBusy(false);
|
||||
setUploadFieldError(null);
|
||||
setProportionBlockTitle("");
|
||||
setProportionDefault(50);
|
||||
if (fileInputRef.current) {
|
||||
@@ -135,10 +162,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
}
|
||||
if (fieldTypeModal === "upload") {
|
||||
const t0 = uploadBlockTitle.trim();
|
||||
return (
|
||||
const titleOk =
|
||||
t0.length > 0 &&
|
||||
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
|
||||
);
|
||||
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||
if (!titleOk) return false;
|
||||
if (onPersistCustomUploadFile) {
|
||||
return Boolean(uploadAssetUrl?.trim());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const t0 = proportionBlockTitle.trim();
|
||||
return (
|
||||
@@ -154,6 +185,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
proportionDefault,
|
||||
textBlockTitle,
|
||||
uploadBlockTitle,
|
||||
uploadAssetUrl,
|
||||
onPersistCustomUploadFile,
|
||||
]);
|
||||
|
||||
const headerTitle =
|
||||
@@ -213,13 +246,35 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
}, [resetFieldTypeDrafts]);
|
||||
|
||||
const handleFileChosen = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
setUploadFileName(file?.name);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldError(null);
|
||||
if (!file || !onPersistCustomUploadFile) return;
|
||||
setUploadFieldBusy(true);
|
||||
try {
|
||||
const { url } = await onPersistCustomUploadFile(file);
|
||||
setUploadAssetUrl(url);
|
||||
} catch {
|
||||
setUploadFieldError(tUpload("errors.generic"));
|
||||
} finally {
|
||||
setUploadFieldBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[onPersistCustomUploadFile, tUpload],
|
||||
);
|
||||
|
||||
const handleClearPendingUpload = useCallback(() => {
|
||||
setUploadFileName(undefined);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldError(null);
|
||||
setUploadFieldBusy(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBadgeAddOption = useCallback((label: string) => {
|
||||
setBadgeOptions((prev) =>
|
||||
prev.includes(label) ? prev : [...prev, label],
|
||||
@@ -253,6 +308,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
id,
|
||||
blockTitle: uploadBlockTitle.trim(),
|
||||
fileName: uploadFileName,
|
||||
...(uploadAssetUrl?.trim()
|
||||
? { assetUrl: uploadAssetUrl.trim() }
|
||||
: {}),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -276,6 +334,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
textPlaceholderBody,
|
||||
uploadBlockTitle,
|
||||
uploadFileName,
|
||||
uploadAssetUrl,
|
||||
]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
@@ -337,6 +396,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
onUploadBlockTitleChange: setUploadBlockTitle,
|
||||
fileInputRef,
|
||||
onFileChosen: handleFileChosen,
|
||||
onClearPendingUpload: handleClearPendingUpload,
|
||||
uploadAssetPreviewUrl: uploadAssetUrl,
|
||||
uploadPersisting:
|
||||
Boolean(fieldTypeModal === "upload" && uploadFieldBusy),
|
||||
uploadBusyHint: tUpload("uploading"),
|
||||
uploadErrorMessage:
|
||||
fieldTypeModal === "upload" ? uploadFieldError : null,
|
||||
proportionBlockTitle,
|
||||
proportionDefault,
|
||||
onProportionBlockTitleChange: setProportionBlockTitle,
|
||||
@@ -348,6 +414,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
stepper={!fieldTypeModal}
|
||||
draftFieldBlocks={draftFieldBlocks}
|
||||
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface CustomMethodCardWizardFieldBodiesCopy {
|
||||
blockTitlePlaceholder: string;
|
||||
uploadFileInputAriaLabel: string;
|
||||
uploadHint: string;
|
||||
uploadPreviewImageAlt: string;
|
||||
clearPendingUploadAriaLabel: string;
|
||||
clearPendingUploadTooltip: string;
|
||||
};
|
||||
proportion: {
|
||||
blockTitleLabel: string;
|
||||
@@ -35,6 +38,11 @@ export interface CustomMethodCardWizardCopy {
|
||||
step1: { title: string; description: string; fieldPlaceholder: string };
|
||||
step2: { title: string; description: string; fieldPlaceholder: string };
|
||||
step3: { title: string; description: string };
|
||||
step3BlocksList: {
|
||||
listLabel: string;
|
||||
dragHandleAriaLabel: string;
|
||||
};
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
footerFinalize: string;
|
||||
fieldModals: {
|
||||
addField: string;
|
||||
@@ -67,6 +75,11 @@ export interface CustomMethodCardWizardProps {
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => void;
|
||||
/**
|
||||
* Persists custom-method upload files to `POST /api/uploads` (purpose
|
||||
* `customMethodAttachment`). When omitted, upload field only stores `fileName`.
|
||||
*/
|
||||
onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>;
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardFieldBodiesViewProps {
|
||||
@@ -84,6 +97,15 @@ export interface CustomMethodCardWizardFieldBodiesViewProps {
|
||||
onUploadBlockTitleChange: (_v: string) => void;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
/** Clears chosen file, preview URL, and related errors so the user can pick again. */
|
||||
onClearPendingUpload: () => void;
|
||||
/** When set after a successful upload, shows an inline image preview in the modal. */
|
||||
uploadAssetPreviewUrl?: string | null;
|
||||
/** Shown under the upload control while saving to the server. */
|
||||
uploadPersisting?: boolean;
|
||||
/** Replaces upload hint text while `uploadPersisting` is true. */
|
||||
uploadBusyHint?: string;
|
||||
uploadErrorMessage?: string | null;
|
||||
proportionBlockTitle: string;
|
||||
proportionDefault: number;
|
||||
onProportionBlockTitleChange: (_v: string) => void;
|
||||
@@ -111,6 +133,8 @@ export interface CustomMethodCardWizardViewProps {
|
||||
CustomMethodCardWizardFieldBodiesViewProps,
|
||||
"fieldType" | "copy"
|
||||
>;
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[];
|
||||
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
nextDisabled: boolean;
|
||||
nextLabel: string;
|
||||
showBackButton: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||
import TextArea from "../../../../components/controls/TextArea";
|
||||
import AddCustomField from "../../../../components/controls/AddCustomField";
|
||||
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
|
||||
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
|
||||
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
|
||||
|
||||
function CustomMethodCardWizardViewComponent({
|
||||
@@ -32,6 +33,8 @@ function CustomMethodCardWizardViewComponent({
|
||||
onBack,
|
||||
onNext,
|
||||
stepper,
|
||||
draftFieldBlocks,
|
||||
onDraftFieldBlocksReorder,
|
||||
}: CustomMethodCardWizardViewProps) {
|
||||
return (
|
||||
<Create
|
||||
@@ -79,11 +82,22 @@ function CustomMethodCardWizardViewComponent({
|
||||
/>
|
||||
) : null}
|
||||
{!fieldTypeModal && wizardStep === 3 ? (
|
||||
<AddCustomField
|
||||
active={addFieldExpanded}
|
||||
onPressAdd={onPressAddCustomField}
|
||||
onSelectFieldType={onSelectFieldType}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{draftFieldBlocks.length > 0 ? (
|
||||
<CustomMethodCardWizardBlocksListView
|
||||
blocks={draftFieldBlocks}
|
||||
fieldTypeLabels={copy.fieldTypeLabels}
|
||||
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
||||
listLabel={copy.step3BlocksList.listLabel}
|
||||
onBlocksReorder={onDraftFieldBlocksReorder}
|
||||
/>
|
||||
) : null}
|
||||
<AddCustomField
|
||||
active={addFieldExpanded}
|
||||
onPressAdd={onPressAddCustomField}
|
||||
onSelectFieldType={onSelectFieldType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Create>
|
||||
);
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState, type DragEvent } from "react";
|
||||
import Icon from "../../../../components/asset/icon";
|
||||
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||
|
||||
function DragHandleGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardBlocksListViewProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
dragHandleAriaLabel: string;
|
||||
listLabel: string;
|
||||
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function CustomMethodCardWizardBlocksListViewComponent({
|
||||
blocks,
|
||||
fieldTypeLabels,
|
||||
dragHandleAriaLabel,
|
||||
listLabel,
|
||||
onBlocksReorder,
|
||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
|
||||
const clearDragUi = useCallback(() => {
|
||||
setDraggingIndex(null);
|
||||
setOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
setDraggingIndex(index);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((index: number) => {
|
||||
return (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setOverIndex(index);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||
if (Number.isNaN(from)) {
|
||||
clearDragUi();
|
||||
return;
|
||||
}
|
||||
onBlocksReorder(
|
||||
reorderCustomMethodCardFieldBlocks(blocks, from, index),
|
||||
);
|
||||
clearDragUi();
|
||||
},
|
||||
[blocks, clearDragUi, onBlocksReorder],
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
||||
{blocks.map((block, index) => {
|
||||
const kind = block.kind as AddCustomFieldType;
|
||||
const typeLabel = fieldTypeLabels[kind];
|
||||
const isOver = overIndex === index && draggingIndex !== index;
|
||||
return (
|
||||
<li
|
||||
key={block.id}
|
||||
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
|
||||
isOver
|
||||
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
||||
: ""
|
||||
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
||||
onDragOver={handleDragOver(index)}
|
||||
onDrop={handleDrop(index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={handleDragStart(index)}
|
||||
onDragEnd={clearDragUi}
|
||||
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||
aria-label={dragHandleAriaLabel}
|
||||
>
|
||||
<DragHandleGlyph />
|
||||
</button>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
||||
<Icon
|
||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
||||
size={24}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
||||
{block.blockTitle.trim() || typeLabel}
|
||||
</span>
|
||||
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomMethodCardWizardBlocksListView = memo(
|
||||
CustomMethodCardWizardBlocksListViewComponent,
|
||||
);
|
||||
CustomMethodCardWizardBlocksListView.displayName =
|
||||
"CustomMethodCardWizardBlocksListView";
|
||||
+56
-4
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||
import TextArea from "../../../../components/controls/TextArea";
|
||||
import TextInput from "../../../../components/controls/TextInput";
|
||||
@@ -28,11 +29,19 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||
onUploadBlockTitleChange,
|
||||
fileInputRef,
|
||||
onFileChosen,
|
||||
onClearPendingUpload,
|
||||
uploadAssetPreviewUrl = null,
|
||||
uploadPersisting = false,
|
||||
uploadBusyHint,
|
||||
uploadErrorMessage = null,
|
||||
proportionBlockTitle,
|
||||
proportionDefault,
|
||||
onProportionBlockTitleChange,
|
||||
onProportionDefaultChange,
|
||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||
|
||||
if (fieldType === "text") {
|
||||
return (
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||
@@ -120,10 +129,53 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||
showHelpIcon
|
||||
/>
|
||||
<Upload
|
||||
hintText={copy.upload.uploadHint}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
{hasUploadPreview ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearPendingUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={copy.upload.clearPendingUploadAriaLabel}
|
||||
title={copy.upload.clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- blob or same-origin upload URL */}
|
||||
<img
|
||||
src={uploadPreviewTrimmed}
|
||||
alt={copy.upload.uploadPreviewImageAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!uploadPersisting}
|
||||
hintText={
|
||||
uploadPersisting && uploadBusyHint
|
||||
? uploadBusyHint
|
||||
: copy.upload.uploadHint
|
||||
}
|
||||
onClick={() => {
|
||||
if (!uploadPersisting) fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uploadErrorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{uploadErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user