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
+4
-4
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
ArrowsPointingOut,
|
||||
ArrowUpRightOnBox,
|
||||
Check,
|
||||
SquareTwoStack,
|
||||
TriangleDownMini,
|
||||
@@ -30,7 +30,7 @@ export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
|
||||
<Container className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Heading level="h2">{t("json.header")}</Heading>
|
||||
<Badge size="2xsmall">
|
||||
<Badge size="2xsmall" rounded="full">
|
||||
{t("json.numberOfKeys", {
|
||||
count: numberOfKeys,
|
||||
})}
|
||||
@@ -41,9 +41,9 @@ export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-subtle"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<ArrowsPointingOut />
|
||||
<ArrowUpRightOnBox />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content className="bg-ui-contrast-bg-base text-ui-code-fg-subtle !shadow-elevation-commandbar overflow-hidden border border-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./metadata-section"
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { ArrowUpRightOnBox } from "@medusajs/icons"
|
||||
import { Badge, Container, Heading, IconButton } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type MetadataSectionProps<TData extends object> = {
|
||||
data: TData
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const MetadataSection = <TData extends object>({
|
||||
data,
|
||||
href = "metadata/edit",
|
||||
}: MetadataSectionProps<TData>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!("metadata" in data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numberOfKeys = data.metadata ? Object.keys(data.metadata).length : 0
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Heading level="h2">{t("metadata.header")}</Heading>
|
||||
<Badge size="2xsmall" rounded="full">
|
||||
{t("metadata.numberOfKeys", {
|
||||
count: numberOfKeys,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
asChild
|
||||
>
|
||||
<Link to={href}>
|
||||
<ArrowUpRightOnBox />
|
||||
</Link>
|
||||
</IconButton>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -229,3 +229,99 @@ export const JsonViewSectionSkeleton = () => {
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
type SingleColumnPageSkeletonProps = {
|
||||
sections?: number
|
||||
showJSON?: boolean
|
||||
showMetadata?: boolean
|
||||
}
|
||||
|
||||
export const SingleColumnPageSkeleton = ({
|
||||
sections = 2,
|
||||
showJSON = false,
|
||||
showMetadata = false,
|
||||
}: SingleColumnPageSkeletonProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{Array.from({ length: sections }, (_, i) => i).map((section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||
// First section is smaller on most pages, this gives us less
|
||||
// layout shifting in general,
|
||||
"max-h-[219px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{showMetadata && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TwoColumnPageSkeletonProps = {
|
||||
mainSections?: number
|
||||
sidebarSections?: number
|
||||
showJSON?: boolean
|
||||
showMetadata?: boolean
|
||||
}
|
||||
|
||||
export const TwoColumnPageSkeleton = ({
|
||||
mainSections = 2,
|
||||
sidebarSections = 1,
|
||||
showJSON = false,
|
||||
showMetadata = true,
|
||||
}: TwoColumnPageSkeletonProps) => {
|
||||
const showExtraData = showJSON || showMetadata
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
{Array.from({ length: mainSections }, (_, i) => i).map((section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[460px] w-full rounded-lg", {
|
||||
"max-h-[219px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{showExtraData && (
|
||||
<div className="hidden flex-col gap-y-3 xl:flex">
|
||||
{showMetadata && (
|
||||
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||
)}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[440px]">
|
||||
{Array.from({ length: sidebarSections }, (_, i) => i).map(
|
||||
(section) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={section}
|
||||
className={clx("h-full max-h-[320px] w-full rounded-lg", {
|
||||
"max-h-[140px]": section === 0,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
{showExtraData && (
|
||||
<div className="flex flex-col gap-y-3 xl:hidden">
|
||||
{showMetadata && (
|
||||
<Skeleton className="h-[60px] w-full rounded-lg" />
|
||||
)}
|
||||
{showJSON && <Skeleton className="h-[60px] w-full rounded-lg" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+26
-1
@@ -1,13 +1,27 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../common/json-view-section"
|
||||
import { MetadataSection } from "../../../common/metadata-section"
|
||||
import { PageProps } from "../types"
|
||||
|
||||
export const SingleColumnPage = <TData,>({
|
||||
children,
|
||||
widgets,
|
||||
/**
|
||||
* Data of the page which is passed to Widgets, JSON view, and Metadata view.
|
||||
*/
|
||||
data,
|
||||
hasOutlet,
|
||||
/**
|
||||
* Whether the page should render an outlet for children routes. Defaults to true.
|
||||
*/
|
||||
hasOutlet = true,
|
||||
/**
|
||||
* Whether to show JSON view of the data. Defaults to false.
|
||||
*/
|
||||
showJSON,
|
||||
/**
|
||||
* Whether to show metadata view of the data. Defaults to false.
|
||||
*/
|
||||
showMetadata,
|
||||
}: PageProps<TData>) => {
|
||||
const { before, after } = widgets
|
||||
const widgetProps = { data }
|
||||
@@ -22,6 +36,16 @@ export const SingleColumnPage = <TData,>({
|
||||
showJSON = false
|
||||
}
|
||||
|
||||
if (showMetadata && !data) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"`showMetadata` is true but no data is provided. To display metadata, provide data prop."
|
||||
)
|
||||
}
|
||||
|
||||
showMetadata = false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
@@ -31,6 +55,7 @@ export const SingleColumnPage = <TData,>({
|
||||
{after.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showMetadata && <MetadataSection data={data!} />}
|
||||
{showJSON && <JsonViewSection data={data!} />}
|
||||
{hasOutlet && <Outlet />}
|
||||
</div>
|
||||
|
||||
+27
-9
@@ -2,6 +2,7 @@ import { clx } from "@medusajs/ui"
|
||||
import { Children, ComponentPropsWithoutRef } from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../common/json-view-section"
|
||||
import { MetadataSection } from "../../../common/metadata-section"
|
||||
import { PageProps, WidgetImport, WidgetProps } from "../types"
|
||||
|
||||
interface TwoColumnWidgetProps extends WidgetProps {
|
||||
@@ -20,13 +21,17 @@ const Root = <TData,>({
|
||||
*/
|
||||
widgets,
|
||||
/**
|
||||
* Data to be passed to widgets and the JSON view.
|
||||
* Data to be passed to widgets, JSON view, and Metadata view.
|
||||
*/
|
||||
data,
|
||||
/**
|
||||
* Whether to show JSON view of the data. Defaults to true.
|
||||
* Whether to show JSON view of the data. Defaults to false.
|
||||
*/
|
||||
showJSON = false,
|
||||
/**
|
||||
* Whether to show metadata view of the data. Defaults to false.
|
||||
*/
|
||||
showMetadata = false,
|
||||
/**
|
||||
* Whether to render an outlet for children routes. Defaults to true.
|
||||
*/
|
||||
@@ -45,6 +50,16 @@ const Root = <TData,>({
|
||||
showJSON = false
|
||||
}
|
||||
|
||||
if (showMetadata && !data) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"`showMetadata` is true but no data is provided. To display metadata, provide data prop."
|
||||
)
|
||||
}
|
||||
|
||||
showMetadata = false
|
||||
}
|
||||
|
||||
const childrenArray = Children.toArray(children)
|
||||
|
||||
if (childrenArray.length !== 2) {
|
||||
@@ -52,9 +67,10 @@ const Root = <TData,>({
|
||||
}
|
||||
|
||||
const [main, sidebar] = childrenArray
|
||||
const showExtraData = showJSON || showMetadata
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
})}
|
||||
@@ -64,9 +80,10 @@ const Root = <TData,>({
|
||||
{after.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showJSON && (
|
||||
<div className="hidden xl:block">
|
||||
<JsonViewSection data={data!} root="product" />
|
||||
{showExtraData && (
|
||||
<div className="hidden flex-col gap-y-3 xl:flex">
|
||||
{showMetadata && <MetadataSection data={data!} />}
|
||||
{showJSON && <JsonViewSection data={data!} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -78,9 +95,10 @@ const Root = <TData,>({
|
||||
{sideAfter.widgets.map((w, i) => {
|
||||
return <w.Component {...widgetProps} key={i} />
|
||||
})}
|
||||
{showJSON && (
|
||||
<div className="xl:hidden">
|
||||
<JsonViewSection data={data!} />
|
||||
{showExtraData && (
|
||||
<div className="flex flex-col gap-y-3 xl:hidden">
|
||||
{showMetadata && <MetadataSection data={data!} />}
|
||||
{showJSON && <JsonViewSection data={data!} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,5 +18,6 @@ export interface PageProps<TData> {
|
||||
widgets: WidgetProps
|
||||
data?: TData
|
||||
showJSON?: boolean
|
||||
showMetadata?: boolean
|
||||
hasOutlet?: boolean
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { DeleteResponse, HttpTypes, PaginatedResponse } from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
@@ -19,7 +20,7 @@ export const useCustomer = (
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
{ customer: HttpTypes.AdminCustomer },
|
||||
Error,
|
||||
FetchError,
|
||||
{ customer: HttpTypes.AdminCustomer },
|
||||
QueryKey
|
||||
>,
|
||||
@@ -40,7 +41,7 @@ export const useCustomers = (
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
PaginatedResponse<{ customers: HttpTypes.AdminCustomer[] }>,
|
||||
Error,
|
||||
FetchError,
|
||||
PaginatedResponse<{ customers: HttpTypes.AdminCustomer[] }>,
|
||||
QueryKey
|
||||
>,
|
||||
@@ -59,7 +60,7 @@ export const useCustomers = (
|
||||
export const useCreateCustomer = (
|
||||
options?: UseMutationOptions<
|
||||
{ customer: HttpTypes.AdminCustomer },
|
||||
Error,
|
||||
FetchError,
|
||||
HttpTypes.AdminCreateCustomer
|
||||
>
|
||||
) => {
|
||||
@@ -77,7 +78,7 @@ export const useUpdateCustomer = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<
|
||||
{ customer: HttpTypes.AdminCustomer },
|
||||
Error,
|
||||
FetchError,
|
||||
HttpTypes.AdminUpdateCustomer
|
||||
>
|
||||
) => {
|
||||
@@ -95,7 +96,7 @@ export const useUpdateCustomer = (
|
||||
|
||||
export const useDeleteCustomer = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<DeleteResponse<"customer">, Error, void>
|
||||
options?: UseMutationOptions<DeleteResponse<"customer">, FetchError, void>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: () => sdk.admin.customer.delete(id),
|
||||
|
||||
@@ -268,15 +268,14 @@ export const useProducts = (
|
||||
|
||||
export const useCreateProduct = (
|
||||
options?: UseMutationOptions<
|
||||
{ product: HttpTypes.AdminProduct },
|
||||
Error,
|
||||
HttpTypes.AdminProductResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminCreateProduct
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload: HttpTypes.AdminCreateProduct) =>
|
||||
sdk.admin.product.create(payload),
|
||||
onSuccess: (data: any, variables: any, context: any) => {
|
||||
mutationFn: (payload) => sdk.admin.product.create(payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
|
||||
// if `manage_inventory` is true on created variants that will create inventory items automatically
|
||||
queryClient.invalidateQueries({
|
||||
@@ -290,12 +289,15 @@ export const useCreateProduct = (
|
||||
|
||||
export const useUpdateProduct = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<HttpTypes.AdminProductResponse, Error, any>
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminProductResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminUpdateProduct
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload: HttpTypes.AdminUpdateProduct) =>
|
||||
sdk.admin.product.update(id, payload),
|
||||
onSuccess: (data: any, variables: any, context: any) => {
|
||||
mutationFn: (payload) => sdk.admin.product.update(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: productsQueryKeys.detail(id) })
|
||||
|
||||
@@ -309,7 +311,7 @@ export const useDeleteProduct = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminProductDeleteResponse,
|
||||
Error,
|
||||
FetchError,
|
||||
void
|
||||
>
|
||||
) => {
|
||||
|
||||
@@ -62,6 +62,30 @@
|
||||
"description": "View the JSON data for this object."
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"header": "Metadata",
|
||||
"numberOfKeys_one": "{{count}} key",
|
||||
"numberOfKeys_other": "{{count}} keys",
|
||||
"edit": {
|
||||
"header": "Edit Metadata",
|
||||
"description": "Edit the metadata for this object.",
|
||||
"successToast": "Metadata was successfully updated.",
|
||||
"actions": {
|
||||
"insertRowAbove": "Insert row above",
|
||||
"insertRowBelow": "Insert row below",
|
||||
"deleteRow": "Delete row"
|
||||
},
|
||||
"labels": {
|
||||
"key": "Key",
|
||||
"value": "Value"
|
||||
},
|
||||
"complexRow": {
|
||||
"label": "Some rows are disabled",
|
||||
"description": "This object contains non-primitive metadata, such as arrays or objects, that can't be edited here. To edit the disabled rows, use the API directly.",
|
||||
"tooltip": "This row is disabled because it contains non-primitive data."
|
||||
}
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"mustBeInt": "The value must be a whole number.",
|
||||
"mustBePositive": "The value must be a positive number."
|
||||
@@ -2184,11 +2208,6 @@
|
||||
"draft": "Draft",
|
||||
"values": "Values"
|
||||
},
|
||||
"metadata": {
|
||||
"warnings": {
|
||||
"ignoredKeys": "This entities metadata contains complex values that we currently don't support editing through the admin UI. Due to this, the following keys are currently not being displayed: {{keys}}. You can still edit these values using the API."
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"years_one": "Year",
|
||||
"years_other": "Years",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
export type MetadataField = {
|
||||
key: string
|
||||
value: string
|
||||
/**
|
||||
* Is the field provided as initial data
|
||||
*/
|
||||
isInitial?: boolean
|
||||
/**
|
||||
* Whether the row was deleted
|
||||
*/
|
||||
isDeleted?: boolean
|
||||
/**
|
||||
* True for initial values that are not primitives
|
||||
*/
|
||||
isIgnored?: boolean
|
||||
}
|
||||
|
||||
const isPrimitive = (value: any): boolean => {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert metadata property to an array of form filed values.
|
||||
*/
|
||||
export const metadataToFormValues = (
|
||||
metadata?: Record<string, any> | null
|
||||
): MetadataField[] => {
|
||||
const data: MetadataField[] = []
|
||||
|
||||
if (metadata) {
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
data.push({
|
||||
key,
|
||||
value: value as string,
|
||||
isInitial: true,
|
||||
isIgnored: !isPrimitive(value),
|
||||
isDeleted: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// DEFAULT field for adding a new metadata record
|
||||
// it's added here so it's registered as a default value
|
||||
data.push({
|
||||
key: "",
|
||||
value: "",
|
||||
isInitial: false,
|
||||
isIgnored: false,
|
||||
isDeleted: false,
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a form fields array to a metadata object
|
||||
*/
|
||||
export const formValuesToMetadata = (
|
||||
data: MetadataField[]
|
||||
): Record<string, unknown> => {
|
||||
return data.reduce((acc, { key, value, isDeleted, isIgnored, isInitial }) => {
|
||||
if (isIgnored) {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}
|
||||
|
||||
if (isDeleted && isInitial) {
|
||||
acc[key] = ""
|
||||
return acc
|
||||
}
|
||||
|
||||
if (key) {
|
||||
acc[key] = value // TODO: since these are primitives should we parse strings to their primitive format e.g. "123" -> 123 , "true" -> true
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
@@ -114,6 +114,11 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/products/product-create-variant"),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
import("../../routes/products/product-metadata"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -427,6 +432,11 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/customers/customers-add-customer-group"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
import("../../routes/customers/customer-metadata"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -475,6 +485,13 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../routes/customer-groups/customer-group-add-customers"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "metadata/edit",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/customer-groups/customer-group-metadata"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+14
-21
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useCustomerGroup } from "../../../hooks/api/customer-groups"
|
||||
import { CustomerGroupCustomerSection } from "./components/customer-group-customer-section"
|
||||
import { CustomerGroupGeneralSection } from "./components/customer-group-general-section"
|
||||
@@ -8,6 +8,7 @@ import { customerGroupLoader } from "./loader"
|
||||
|
||||
import after from "virtual:medusa/widgets/customer_group/details/after"
|
||||
import before from "virtual:medusa/widgets/customer_group/details/before"
|
||||
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
|
||||
export const CustomerGroupDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
@@ -24,7 +25,7 @@ export const CustomerGroupDetail = () => {
|
||||
)
|
||||
|
||||
if (isLoading || !customer_group) {
|
||||
return <div>Loading...</div>
|
||||
return <SingleColumnPageSkeleton sections={2} showJSON showMetadata />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -32,25 +33,17 @@ export const CustomerGroupDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={customer_group} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
before,
|
||||
after,
|
||||
}}
|
||||
showJSON
|
||||
showMetadata
|
||||
data={customer_group}
|
||||
>
|
||||
<CustomerGroupGeneralSection group={customer_group} />
|
||||
<CustomerGroupCustomerSection group={customer_group} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={customer_group} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<JsonViewSection data={customer_group} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { MetadataForm } from "../../../components/forms/metadata-form"
|
||||
import {
|
||||
useCustomerGroup,
|
||||
useUpdateCustomerGroup,
|
||||
} from "../../../hooks/api/customer-groups"
|
||||
|
||||
export const CustomerGroupMetadata = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { customer_group, isPending, isError, error } = useCustomerGroup(id!)
|
||||
const { mutateAsync, isPending: isMutating } = useUpdateCustomerGroup(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataForm
|
||||
metadata={customer_group?.metadata}
|
||||
hook={mutateAsync}
|
||||
isPending={isPending}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CustomerGroupMetadata as Component } from "./customer-metadata"
|
||||
+15
-21
@@ -1,6 +1,7 @@
|
||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
import { SingleColumnPage } from "../../../components/layout/pages"
|
||||
import { useCustomer } from "../../../hooks/api/customers"
|
||||
import { CustomerGeneralSection } from "./components/customer-general-section"
|
||||
import { CustomerGroupSection } from "./components/customer-group-section"
|
||||
@@ -20,7 +21,7 @@ export const CustomerDetail = () => {
|
||||
})
|
||||
|
||||
if (isLoading || !customer) {
|
||||
return <div>Loading...</div>
|
||||
return <SingleColumnPageSkeleton sections={2} showJSON showMetadata />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -28,28 +29,21 @@ export const CustomerDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={customer} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<SingleColumnPage
|
||||
widgets={{
|
||||
before,
|
||||
after,
|
||||
}}
|
||||
data={customer}
|
||||
hasOutlet
|
||||
showJSON
|
||||
showMetadata
|
||||
>
|
||||
<CustomerGeneralSection customer={customer} />
|
||||
{/* <CustomerOrderSection customer={customer} />
|
||||
// TODO: re-add when order endpoints are added to api-v2
|
||||
*/}
|
||||
<CustomerGroupSection customer={customer} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={customer} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<JsonViewSection data={customer} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</SingleColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
-10
@@ -6,17 +6,11 @@ import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { ConditionalTooltip } from "../../../../../components/common/conditional-tooltip/index.ts"
|
||||
import { Form } from "../../../../../components/common/form/index.ts"
|
||||
import { Metadata } from "../../../../../components/forms/metadata/index.ts"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/modals/index.ts"
|
||||
import { useUpdateCustomer } from "../../../../../hooks/api/customers.tsx"
|
||||
import {
|
||||
formValuesToMetadata,
|
||||
metadataToFormValues,
|
||||
} from "../../../../../lib/metadata.ts"
|
||||
import { metadataFormSchema } from "../../../../../lib/validation.ts"
|
||||
|
||||
type EditCustomerFormProps = {
|
||||
customer: HttpTypes.AdminCustomer
|
||||
@@ -28,7 +22,6 @@ const EditCustomerSchema = zod.object({
|
||||
last_name: zod.string().optional(),
|
||||
company_name: zod.string().optional(),
|
||||
phone: zod.string().optional(),
|
||||
metadata: metadataFormSchema,
|
||||
})
|
||||
|
||||
export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||
@@ -42,7 +35,6 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||
last_name: customer.last_name || "",
|
||||
company_name: customer.company_name || "",
|
||||
phone: customer.phone || "",
|
||||
metadata: metadataToFormValues(customer.metadata),
|
||||
},
|
||||
resolver: zodResolver(EditCustomerSchema),
|
||||
})
|
||||
@@ -57,7 +49,6 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||
last_name: data.last_name || null,
|
||||
phone: data.phone || null,
|
||||
company_name: data.company_name || null,
|
||||
metadata: formValuesToMetadata(data.metadata),
|
||||
},
|
||||
{
|
||||
onSuccess: ({ customer }) => {
|
||||
@@ -161,7 +152,6 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Metadata form={form} />
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { MetadataForm } from "../../../components/forms/metadata-form"
|
||||
import { useCustomer, useUpdateCustomer } from "../../../hooks/api/customers"
|
||||
|
||||
export const CustomerMetadata = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { customer, isPending, isError, error } = useCustomer(id!)
|
||||
const { mutateAsync, isPending: isMutating } = useUpdateCustomer(id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataForm
|
||||
metadata={customer?.metadata}
|
||||
hook={mutateAsync}
|
||||
isPending={isPending}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomerMetadata as Component } from "./customer-metadata"
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { Buildings, Component, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { Badge, usePrompt, clx } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { HttpTypes, InventoryItemDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Badge, clx, usePrompt } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
|
||||
@@ -128,7 +128,7 @@ export const useProductVariantTableColumns = (
|
||||
<Badge
|
||||
size="2xsmall"
|
||||
title={variantOpt.value}
|
||||
className="inline-block min-w-[20px] max-w-[140px] overflow-hidden truncate"
|
||||
className="inline-flex min-w-[20px] max-w-[140px] items-center justify-center overflow-hidden truncate"
|
||||
>
|
||||
{variantOpt.value}
|
||||
</Badge>
|
||||
|
||||
+34
-53
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { TwoColumnPage } from "../../../components/layout/pages"
|
||||
import { useProduct } from "../../../hooks/api/products"
|
||||
import { ProductAttributeSection } from "./components/product-attribute-section"
|
||||
import { ProductGeneralSection } from "./components/product-general-section"
|
||||
@@ -16,6 +16,7 @@ import after from "virtual:medusa/widgets/product/details/after"
|
||||
import before from "virtual:medusa/widgets/product/details/before"
|
||||
import sideAfter from "virtual:medusa/widgets/product/details/side/after"
|
||||
import sideBefore from "virtual:medusa/widgets/product/details/side/before"
|
||||
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
|
||||
|
||||
export const ProductDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
@@ -32,7 +33,14 @@ export const ProductDetail = () => {
|
||||
)
|
||||
|
||||
if (isLoading || !product) {
|
||||
return <div>Loading...</div>
|
||||
return (
|
||||
<TwoColumnPageSkeleton
|
||||
mainSections={4}
|
||||
sidebarSections={3}
|
||||
showJSON
|
||||
showMetadata
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -40,55 +48,28 @@ export const ProductDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<ProductGeneralSection product={product} />
|
||||
<ProductMediaSection product={product} />
|
||||
<ProductOptionSection product={product} />
|
||||
<ProductVariantSection product={product} />
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="hidden xl:block">
|
||||
<JsonViewSection data={product} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[400px]">
|
||||
{sideBefore.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<ProductSalesChannelSection product={product} />
|
||||
<ProductOrganizationSection product={product} />
|
||||
<ProductAttributeSection product={product} />
|
||||
{sideAfter.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component data={product} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="xl:hidden">
|
||||
<JsonViewSection data={product} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
<TwoColumnPage
|
||||
widgets={{
|
||||
after,
|
||||
before,
|
||||
sideAfter,
|
||||
sideBefore,
|
||||
}}
|
||||
showJSON
|
||||
showMetadata
|
||||
data={product}
|
||||
>
|
||||
<TwoColumnPage.Main>
|
||||
<ProductGeneralSection product={product} />
|
||||
<ProductMediaSection product={product} />
|
||||
<ProductOptionSection product={product} />
|
||||
<ProductVariantSection product={product} />
|
||||
</TwoColumnPage.Main>
|
||||
<TwoColumnPage.Sidebar>
|
||||
<ProductSalesChannelSection product={product} />
|
||||
<ProductOrganizationSection product={product} />
|
||||
<ProductAttributeSection product={product} />
|
||||
</TwoColumnPage.Sidebar>
|
||||
</TwoColumnPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductMetadata as Component } from "./product-metadata"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { MetadataForm } from "../../../components/forms/metadata-form/metadata-form"
|
||||
import { useProduct, useUpdateProduct } from "../../../hooks/api"
|
||||
|
||||
export const ProductMetadata = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { product, isPending, isError, error } = useProduct(id!)
|
||||
|
||||
const { mutateAsync, isPending: isMutating } = useUpdateProduct(product.id!)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataForm
|
||||
metadata={product.metadata}
|
||||
hook={mutateAsync}
|
||||
isPending={isPending}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -65,6 +65,6 @@ export type AdminUpdateCustomerGroupType = z.infer<
|
||||
typeof AdminUpdateCustomerGroup
|
||||
>
|
||||
export const AdminUpdateCustomerGroup = z.object({
|
||||
name: z.string(),
|
||||
name: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user