feat(dashboard): metadata component (#7117)
**What** - add new metadata component **Note** - _example of usage on customer edit form_ - we are not handling update metadata case in the internal module service so for now delete case doesn't work properly --- https://github.com/medusajs/medusa/assets/16856471/b588752d-9cf5-4d96-9cf8-760a764ab03e
This commit is contained in:
@@ -1534,6 +1534,11 @@
|
||||
"endDate": "End date",
|
||||
"draft": "Draft"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./metadata"
|
||||
@@ -0,0 +1,157 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
84
packages/admin-next/dashboard/src/lib/metadata.ts
Normal file
84
packages/admin-next/dashboard/src/lib/metadata.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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>)
|
||||
}
|
||||
@@ -32,3 +32,16 @@ export const optionalInt = z
|
||||
message: i18next.t("validation.mustBePositive"),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for metadata form.
|
||||
*/
|
||||
export const metadataFormSchema = z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.unknown(),
|
||||
isInitial: z.boolean().optional(),
|
||||
isDeleted: z.boolean().optional(),
|
||||
isIgnored: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateCustomer } from "../../../../../hooks/api/customers"
|
||||
import { Metadata } from "../../../../../components/forms/metadata"
|
||||
import {
|
||||
formValuesToMetadata,
|
||||
metadataToFormValues,
|
||||
} from "../../../../../lib/metadata.ts"
|
||||
import { metadataFormSchema } from "../../../../../lib/validation"
|
||||
|
||||
type EditCustomerFormProps = {
|
||||
customer: AdminCustomerResponse["customer"]
|
||||
@@ -22,6 +28,7 @@ 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) => {
|
||||
@@ -35,6 +42,7 @@ 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),
|
||||
})
|
||||
@@ -49,6 +57,7 @@ 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 }) => {
|
||||
@@ -156,6 +165,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Metadata form={form} />
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const CustomerEdit = () => {
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("customers.editCustomer")}</Heading>
|
||||
<Heading>{t("customers.edit.header")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{!isLoading && customer && <EditCustomerForm customer={customer} />}
|
||||
</RouteDrawer>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "medusa-react"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
CustomerGroupGeneralForm,
|
||||
CustomerGroupGeneralFormType,
|
||||
@@ -20,7 +21,6 @@ import Modal from "../../../components/molecules/modal"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type CustomerGroupModalProps = {
|
||||
open: boolean
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as QueryConfig from "./query-config"
|
||||
import {
|
||||
AdminCreateCustomer,
|
||||
AdminCreateCustomerAddress,
|
||||
AdminCustomerAdressesParams,
|
||||
AdminCustomerAddressesParams,
|
||||
AdminCustomerParams,
|
||||
AdminCustomersParams,
|
||||
AdminUpdateCustomer,
|
||||
@@ -100,7 +100,7 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
matcher: "/admin/customers/:id/addresses",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
AdminCustomerAdressesParams,
|
||||
AdminCustomerAddressesParams,
|
||||
QueryConfig.listAddressesTransformQueryConfig
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ export const defaultAdminCustomerFields = [
|
||||
"last_name",
|
||||
"email",
|
||||
"phone",
|
||||
"metadata",
|
||||
"has_account",
|
||||
"created_by",
|
||||
"created_at",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const AdminCreateCustomer = z.object({
|
||||
first_name: z.string().optional(),
|
||||
last_name: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const AdminUpdateCustomer = z.object({
|
||||
@@ -56,6 +57,7 @@ export const AdminUpdateCustomer = z.object({
|
||||
first_name: z.string().nullable().optional(),
|
||||
last_name: z.string().nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const AdminCreateCustomerAddress = z.object({
|
||||
@@ -77,7 +79,7 @@ export const AdminCreateCustomerAddress = z.object({
|
||||
|
||||
export const AdminUpdateCustomerAddress = AdminCreateCustomerAddress
|
||||
|
||||
export const AdminCustomerAdressesParams = createFindParams({
|
||||
export const AdminCustomerAddressesParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
|
||||
Reference in New Issue
Block a user