import { zodResolver } from "@hookform/resolvers/zod" import { Button, DropdownMenu, Heading, IconButton, InlineTip, clx, toast, } from "@medusajs/ui" import { useFieldArray, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" import { ArrowDownMini, ArrowUpMini, EllipsisVertical, Trash, } from "@medusajs/icons" import { FetchError } from "@medusajs/js-sdk" import { ComponentPropsWithoutRef, forwardRef } from "react" import { ConditionalTooltip } from "../../common/conditional-tooltip" import { Form } from "../../common/form" import { Skeleton } from "../../common/skeleton" import { RouteDrawer, useRouteModal } from "../../modals" import { KeyboundForm } from "../../utilities/keybound-form" import { useDocumentDirection } from "../../../hooks/use-document-direction" type MetaDataSubmitHook = ( params: { metadata?: Record | null }, callbacks: { onSuccess: () => void; onError: (error: FetchError) => void } ) => Promise type MetadataFormProps = { metadata?: Record | null hook: MetaDataSubmitHook isPending: boolean isMutating: boolean } const MetadataFieldSchema = z.object({ key: z.string(), disabled: z.boolean().optional(), value: z.any(), }) const MetadataSchema = z.object({ metadata: z.array(MetadataFieldSchema), }) export const MetadataForm = (props: MetadataFormProps) => { const { t } = useTranslation() const { isPending, ...innerProps } = props return ( {t("metadata.edit.header")} {t("metadata.edit.description")} {isPending ? : } ) } const METADATA_KEY_LABEL_ID = "metadata-form-key-label" const METADATA_VALUE_LABEL_ID = "metadata-form-value-label" const InnerForm = ({ metadata, hook, isMutating, }: Omit, "isPending">) => { const { t } = useTranslation() const { handleSuccess } = useRouteModal() const direction = useDocumentDirection() const hasUneditableRows = getHasUneditableRows(metadata) const form = useForm>({ defaultValues: { metadata: getDefaultValues(metadata), }, resolver: zodResolver(MetadataSchema), }) const handleSubmit = form.handleSubmit(async (data) => { const parsedData = parseValues(data, metadata) await hook( { metadata: parsedData, }, { onSuccess: () => { toast.success(t("metadata.edit.successToast")) handleSuccess() }, onError: (error) => { toast.error(error.message) }, } ) }) const { fields, insert, remove } = useFieldArray({ control: form.control, name: "metadata", }) function deleteRow(index: number) { remove(index) // If the last row is deleted, add a new blank row if (fields.length === 1) { insert(0, { key: "", value: "", disabled: false, }) } } function insertRow(index: number, position: "above" | "below") { insert(index + (position === "above" ? 0 : 1), { key: "", value: "", disabled: false, }) } return (
{fields.map((field, index) => { const isDisabled = field.disabled || false let placeholder = "-" if (typeof field.value === "object") { placeholder = "{ ... }" } if (Array.isArray(field.value)) { placeholder = "[ ... ]" } return (
{ return ( ) }} /> { return ( ) }} />
insertRow(index, "above")} > {t("metadata.edit.actions.insertRowAbove")} insertRow(index, "below")} > {t("metadata.edit.actions.insertRowBelow")} deleteRow(index)} > {t("metadata.edit.actions.deleteRow")}
) })}
{hasUneditableRows && ( {t("metadata.edit.complexRow.description")} )}
) } const GridInput = forwardRef< HTMLInputElement, ComponentPropsWithoutRef<"input"> >(({ className, ...props }, ref) => { return ( ) }) GridInput.displayName = "MetadataForm.GridInput" const PlaceholderInner = () => { return (
) } const EDITABLE_TYPES = ["string", "number", "boolean"] function getDefaultValues( metadata?: Record | null ): z.infer[] { if (!metadata || !Object.keys(metadata).length) { return [ { key: "", value: "", disabled: false, }, ] } return Object.entries(metadata).map(([key, value]) => { if (!EDITABLE_TYPES.includes(typeof value)) { return { key, value: value, disabled: true, } } let stringValue = value if (typeof value !== "string") { stringValue = JSON.stringify(value) } return { key, value: stringValue, original_key: key, } }) } function parseValues( values: z.infer, original?: Record | null ): Record | null { const metadata = values.metadata const isEmpty = !metadata.length || (metadata.length === 1 && !metadata[0].key && !metadata[0].value) if (isEmpty) { return null } const update: Record = {} // First, handle removed keys from original if (original) { Object.keys(original).forEach((originalKey) => { const exists = metadata.some((field) => field.key === originalKey) if (!exists) { update[originalKey] = "" } }) } metadata.forEach((field) => { let key = field.key let value = field.value const disabled = field.disabled if (!key) { return } if (disabled) { update[key] = value return } key = key.trim() value = value?.trim() ?? "" // We try to cast the value to a boolean or number if possible if (value === "true") { update[key] = true } else if (value === "false") { update[key] = false } else { const isNumeric = /^-?\d*\.?\d+$/.test(value) if (isNumeric) { update[key] = parseFloat(value) } else { update[key] = value } } }) return update } function getHasUneditableRows(metadata?: Record | null) { if (!metadata) { return false } return Object.values(metadata).some( (value) => !EDITABLE_TYPES.includes(typeof value) ) }