feat(dashboard,medusa): Add updated Metadata Form (#8084)
**What** - Adds new Metadata form component. - Adds the Metadata section as an option to the Page layouts <img width="576" alt="Skærmbillede 2024-07-11 kl 11 34 06" src="https://github.com/medusajs/medusa/assets/45367945/417810ee-26e2-4c8a-86e3-58ef327054af"> <img width="580" alt="Skærmbillede 2024-07-11 kl 11 34 33" src="https://github.com/medusajs/medusa/assets/45367945/437a5e01-01e2-4ff7-8c7e-42a86d1ce2b3"> **Note** - When Metadata contains non-primitive data, we disable those rows, and show a placeholder value, a tooltip and an alert describing that the row can be edited through the API. I want to add a JSON editor to allow editing these things in admin, but awaiting approval on that. - This PR only adds the new form to a couple of pages, to keep the PR light, especially since metadata is not implemented correctly in all validators so also needs some changes to the core. This still show some examples of how its used with the new Page layout components. Will follow up with more pages in future PRs. - We try to convert the inputs to the best fitting primitive, so if a user types "true" then we save the value as a boolean, "130" as number, "testing" as a string, etc.
This commit is contained in:
committed by
GitHub
parent
66acb3023e
commit
b5a44ef6b1
@@ -0,0 +1 @@
|
||||
export * from "./metadata-form"
|
||||
@@ -0,0 +1,415 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
IconButton,
|
||||
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, useRef } from "react"
|
||||
import { ConditionalTooltip } from "../../common/conditional-tooltip"
|
||||
import { Form } from "../../common/form"
|
||||
import { InlineTip } from "../../common/inline-tip"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { RouteDrawer, useRouteModal } from "../../modals"
|
||||
|
||||
type MetaDataSubmitHook<TRes> = (
|
||||
params: { metadata?: Record<string, any> | null },
|
||||
callbacks: { onSuccess: () => void; onError: (error: FetchError) => void }
|
||||
) => Promise<TRes>
|
||||
|
||||
type MetadataFormProps<TRes> = {
|
||||
metadata?: Record<string, any> | null
|
||||
hook: MetaDataSubmitHook<TRes>
|
||||
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 = <TRes,>(props: MetadataFormProps<TRes>) => {
|
||||
const { t } = useTranslation()
|
||||
const { isPending, ...innerProps } = props
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<RouteDrawer.Title asChild>
|
||||
<Heading>{t("metadata.edit.header")}</Heading>
|
||||
</RouteDrawer.Title>
|
||||
<RouteDrawer.Description className="sr-only">
|
||||
{t("metadata.edit.description")}
|
||||
</RouteDrawer.Description>
|
||||
</RouteDrawer.Header>
|
||||
{isPending ? <PlaceholderInner /> : <InnerForm {...innerProps} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const METADATA_KEY_LABEL_ID = "metadata-form-key-label"
|
||||
const METADATA_VALUE_LABEL_ID = "metadata-form-value-label"
|
||||
|
||||
const InnerForm = <TRes,>({
|
||||
metadata,
|
||||
hook,
|
||||
isMutating,
|
||||
}: Omit<MetadataFormProps<TRes>, "isPending">) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const deletedOriginalRows = useRef<string[]>([])
|
||||
const hasUneditableRows = getHasUneditableRows(metadata)
|
||||
|
||||
const form = useForm<z.infer<typeof MetadataSchema>>({
|
||||
defaultValues: {
|
||||
metadata: getDefaultValues(metadata),
|
||||
},
|
||||
resolver: zodResolver(MetadataSchema),
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const parsedData = parseValues(data)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function insertRow(index: number, position: "above" | "below") {
|
||||
insert(index + (position === "above" ? 0 : 1), {
|
||||
key: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-y-auto">
|
||||
<div className="bg-ui-bg-base shadow-elevation-card-rest grid grid-cols-1 divide-y rounded-lg">
|
||||
<div className="bg-ui-bg-subtle grid grid-cols-2 divide-x rounded-t-lg">
|
||||
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
|
||||
<label id={METADATA_KEY_LABEL_ID}>
|
||||
{t("metadata.edit.labels.key")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
|
||||
<label id={METADATA_VALUE_LABEL_ID}>
|
||||
{t("metadata.edit.labels.value")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field, index) => {
|
||||
const isDisabled = field.disabled || false
|
||||
let placeholder = "-"
|
||||
|
||||
if (typeof field.value === "object") {
|
||||
placeholder = "{ ... }"
|
||||
}
|
||||
|
||||
if (Array.isArray(field.value)) {
|
||||
placeholder = "[ ... ]"
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalTooltip
|
||||
showTooltip={isDisabled}
|
||||
content={t("metadata.edit.complexRow.tooltip")}
|
||||
key={field.id}
|
||||
>
|
||||
<div className="group/table relative">
|
||||
<div
|
||||
className={clx("grid grid-cols-2 divide-x", {
|
||||
"overflow-hidden rounded-b-lg":
|
||||
index === fields.length - 1,
|
||||
})}
|
||||
>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<GridInput
|
||||
aria-labelledby={METADATA_KEY_LABEL_ID}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
placeholder="Key"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: { value, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<GridInput
|
||||
aria-labelledby={METADATA_VALUE_LABEL_ID}
|
||||
{...field}
|
||||
value={isDisabled ? placeholder : value}
|
||||
disabled={isDisabled}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
className={clx(
|
||||
"invisible absolute inset-y-0 -right-2.5 my-auto group-hover/table:visible data-[state='open']:visible",
|
||||
{
|
||||
hidden: isDisabled,
|
||||
}
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
asChild
|
||||
>
|
||||
<IconButton size="2xsmall">
|
||||
<EllipsisVertical />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => insertRow(index, "above")}
|
||||
>
|
||||
<ArrowUpMini className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.insertRowAbove")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => insertRow(index, "below")}
|
||||
>
|
||||
<ArrowDownMini className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.insertRowBelow")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => deleteRow(index)}
|
||||
>
|
||||
<Trash className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.deleteRow")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ConditionalTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{hasUneditableRows && (
|
||||
<InlineTip
|
||||
variant="warning"
|
||||
label={t("metadata.edit.complexRow.label")}
|
||||
>
|
||||
{t("metadata.edit.complexRow.description")}
|
||||
</InlineTip>
|
||||
)}
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={isMutating}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button size="small" type="submit" isLoading={isMutating}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const GridInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
ComponentPropsWithoutRef<"input">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
{...props}
|
||||
autoComplete="off"
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-base placeholder:text-ui-fg-muted disabled:text-ui-fg-disabled disabled:bg-ui-bg-base px-2 py-1.5 outline-none",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
GridInput.displayName = "MetadataForm.GridInput"
|
||||
|
||||
const PlaceholderInner = () => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<RouteDrawer.Body>
|
||||
<Skeleton className="h-[148ox] w-full rounded-lg" />
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Skeleton className="h-7 w-12 rounded-md" />
|
||||
<Skeleton className="h-7 w-12 rounded-md" />
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EDITABLE_TYPES = ["string", "number", "boolean"]
|
||||
|
||||
function getDefaultValues(
|
||||
metadata?: Record<string, any> | null
|
||||
): z.infer<typeof MetadataFieldSchema>[] {
|
||||
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<typeof MetadataSchema>
|
||||
): Record<string, any> | 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<string, any> = {}
|
||||
|
||||
metadata.forEach((field) => {
|
||||
let key = field.key
|
||||
let value = field.value
|
||||
const disabled = field.disabled
|
||||
|
||||
if (!key || !value) {
|
||||
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 parsedNumber = parseFloat(value)
|
||||
if (!isNaN(parsedNumber)) {
|
||||
update[key] = parsedNumber
|
||||
} else {
|
||||
update[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function getHasUneditableRows(metadata?: Record<string, any> | null) {
|
||||
if (!metadata) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Object.values(metadata).some(
|
||||
(value) => !EDITABLE_TYPES.includes(typeof value)
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./metadata"
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Alert, Button, Input, Text } from "@medusajs/ui"
|
||||
import { Trash } from "@medusajs/icons"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
|
||||
import { MetadataField } from "../../../lib/metadata"
|
||||
|
||||
type MetadataProps = {
|
||||
form: UseFormReturn<MetadataField[]>
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
field: MetadataField
|
||||
isLast: boolean
|
||||
onDelete: () => void
|
||||
updateKey: (key: string) => void
|
||||
updateValue: (value: string) => void
|
||||
}
|
||||
|
||||
function Field({
|
||||
field,
|
||||
updateKey,
|
||||
updateValue,
|
||||
onDelete,
|
||||
isLast,
|
||||
}: FieldProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* value on the index of deleted field will be undefined,
|
||||
* but we need to keep it to preserve list ordering
|
||||
* so React could correctly render elements when adding/deleting
|
||||
*/
|
||||
if (field.isDeleted || field.isIgnored) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group divide-x [&:not(:last-child)]:border-b">
|
||||
<td>
|
||||
<Input
|
||||
className="rounded-none border-none bg-transparent !shadow-none"
|
||||
placeholder={t("fields.key")}
|
||||
defaultValue={field.key}
|
||||
onChange={(e) => {
|
||||
updateKey(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="relative">
|
||||
<Input
|
||||
className="rounded-none border-none bg-transparent pr-[40px] !shadow-none"
|
||||
placeholder={t("fields.value")}
|
||||
defaultValue={field.value}
|
||||
onChange={(e) => {
|
||||
updateValue(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
{!isLast && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-subtle invisible absolute right-0 top-0 h-[32px] w-[32px] p-0 hover:bg-transparent group-hover:visible"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function Metadata({ form }: MetadataProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const metadataWatch = form.watch("metadata") as MetadataField[]
|
||||
const ignoredKeys = metadataWatch.filter((k) => k.isIgnored)
|
||||
|
||||
const addKeyPair = () => {
|
||||
form.setValue(
|
||||
`metadata.${metadataWatch.length ? metadataWatch.length : 0}`,
|
||||
{ key: "", value: "" }
|
||||
)
|
||||
}
|
||||
|
||||
const onKeyChange = (index: number) => {
|
||||
return (key: string) => {
|
||||
form.setValue(`metadata.${index}.key`, key, { shouldDirty: true })
|
||||
|
||||
if (index === metadataWatch.length - 1) {
|
||||
addKeyPair()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onValueChange = (index: number) => {
|
||||
return (value: any) => {
|
||||
form.setValue(`metadata.${index}.value`, value, { shouldDirty: true })
|
||||
|
||||
if (index === metadataWatch.length - 1) {
|
||||
addKeyPair()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKeyPair = (index: number) => {
|
||||
return () => {
|
||||
form.setValue(`metadata.${index}.isDeleted`, true, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text weight="plus" size="small">
|
||||
{t("fields.metadata")}
|
||||
</Text>
|
||||
<table className="shadow-elevation-card-rest mt-2 w-full table-fixed overflow-hidden rounded">
|
||||
<thead>
|
||||
<tr className="bg-ui-bg-field divide-x border-b">
|
||||
<th>
|
||||
<Text className="px-2 py-1 text-left" weight="plus" size="small">
|
||||
{t("fields.key")}
|
||||
</Text>
|
||||
</th>
|
||||
<th>
|
||||
<Text className="px-2 py-1 text-left" weight="plus" size="small">
|
||||
{t("fields.value")}
|
||||
</Text>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metadataWatch.map((field, index) => {
|
||||
return (
|
||||
<Field
|
||||
key={index}
|
||||
field={field}
|
||||
updateKey={onKeyChange(index)}
|
||||
updateValue={onValueChange(index)}
|
||||
onDelete={deleteKeyPair(index)}
|
||||
isLast={index === metadataWatch.length - 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!!ignoredKeys.length && (
|
||||
<Alert variant="warning" className="mt-2" dismissible>
|
||||
{t("metadata.warnings.ignoredKeys", { keys: ignoredKeys.join(",") })}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user