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:
1 parent
ea1d926dfa
commit
155e276b90
11 files changed
+278
-5
No files matched your search
@@ -1534,6 +1534,11 @@
|
|||||||
"endDate": "End date",
|
"endDate": "End date",
|
||||||
"draft": "Draft"
|
"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": {
|
"dateTime": {
|
||||||
"years_one": "Year",
|
"years_one": "Year",
|
||||||
"years_other": "Years",
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"),
|
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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
+10
@@ -11,6 +11,12 @@ import {
|
|||||||
useRouteModal,
|
useRouteModal,
|
||||||
} from "../../../../../components/route-modal"
|
} from "../../../../../components/route-modal"
|
||||||
import { useUpdateCustomer } from "../../../../../hooks/api/customers"
|
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 = {
|
type EditCustomerFormProps = {
|
||||||
customer: AdminCustomerResponse["customer"]
|
customer: AdminCustomerResponse["customer"]
|
||||||
@@ -22,6 +28,7 @@ const EditCustomerSchema = zod.object({
|
|||||||
last_name: zod.string().optional(),
|
last_name: zod.string().optional(),
|
||||||
company_name: zod.string().optional(),
|
company_name: zod.string().optional(),
|
||||||
phone: zod.string().optional(),
|
phone: zod.string().optional(),
|
||||||
|
metadata: metadataFormSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
||||||
@@ -35,6 +42,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
|||||||
last_name: customer.last_name || "",
|
last_name: customer.last_name || "",
|
||||||
company_name: customer.company_name || "",
|
company_name: customer.company_name || "",
|
||||||
phone: customer.phone || "",
|
phone: customer.phone || "",
|
||||||
|
metadata: metadataToFormValues(customer.metadata),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(EditCustomerSchema),
|
resolver: zodResolver(EditCustomerSchema),
|
||||||
})
|
})
|
||||||
@@ -49,6 +57,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
|||||||
last_name: data.last_name || null,
|
last_name: data.last_name || null,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
company_name: data.company_name || null,
|
company_name: data.company_name || null,
|
||||||
|
metadata: formValuesToMetadata(data.metadata),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: ({ customer }) => {
|
onSuccess: ({ customer }) => {
|
||||||
@@ -156,6 +165,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Metadata form={form} />
|
||||||
</div>
|
</div>
|
||||||
</RouteDrawer.Body>
|
</RouteDrawer.Body>
|
||||||
<RouteDrawer.Footer>
|
<RouteDrawer.Footer>
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export const CustomerEdit = () => {
|
|||||||
return (
|
return (
|
||||||
<RouteDrawer>
|
<RouteDrawer>
|
||||||
<RouteDrawer.Header>
|
<RouteDrawer.Header>
|
||||||
<Heading>{t("customers.editCustomer")}</Heading>
|
<Heading>{t("customers.edit.header")}</Heading>
|
||||||
</RouteDrawer.Header>
|
</RouteDrawer.Header>
|
||||||
{!isLoading && customer && <EditCustomerForm customer={customer} />}
|
{!isLoading && customer && <EditCustomerForm customer={customer} />}
|
||||||
</RouteDrawer>
|
</RouteDrawer>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "medusa-react"
|
} from "medusa-react"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import {
|
import {
|
||||||
CustomerGroupGeneralForm,
|
CustomerGroupGeneralForm,
|
||||||
CustomerGroupGeneralFormType,
|
CustomerGroupGeneralFormType,
|
||||||
@@ -20,7 +21,6 @@ import Modal from "../../../components/molecules/modal"
|
|||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
import { nestedForm } from "../../../utils/nested-form"
|
import { nestedForm } from "../../../utils/nested-form"
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
|
|
||||||
type CustomerGroupModalProps = {
|
type CustomerGroupModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as QueryConfig from "./query-config"
|
|||||||
import {
|
import {
|
||||||
AdminCreateCustomer,
|
AdminCreateCustomer,
|
||||||
AdminCreateCustomerAddress,
|
AdminCreateCustomerAddress,
|
||||||
AdminCustomerAdressesParams,
|
AdminCustomerAddressesParams,
|
||||||
AdminCustomerParams,
|
AdminCustomerParams,
|
||||||
AdminCustomersParams,
|
AdminCustomersParams,
|
||||||
AdminUpdateCustomer,
|
AdminUpdateCustomer,
|
||||||
@@ -100,7 +100,7 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
matcher: "/admin/customers/:id/addresses",
|
matcher: "/admin/customers/:id/addresses",
|
||||||
middlewares: [
|
middlewares: [
|
||||||
validateAndTransformQuery(
|
validateAndTransformQuery(
|
||||||
AdminCustomerAdressesParams,
|
AdminCustomerAddressesParams,
|
||||||
QueryConfig.listAddressesTransformQueryConfig
|
QueryConfig.listAddressesTransformQueryConfig
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const defaultAdminCustomerFields = [
|
|||||||
"last_name",
|
"last_name",
|
||||||
"email",
|
"email",
|
||||||
"phone",
|
"phone",
|
||||||
|
"metadata",
|
||||||
"has_account",
|
"has_account",
|
||||||
"created_by",
|
"created_by",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const AdminCreateCustomer = z.object({
|
|||||||
first_name: z.string().optional(),
|
first_name: z.string().optional(),
|
||||||
last_name: z.string().optional(),
|
last_name: z.string().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AdminUpdateCustomer = z.object({
|
export const AdminUpdateCustomer = z.object({
|
||||||
@@ -56,6 +57,7 @@ export const AdminUpdateCustomer = z.object({
|
|||||||
first_name: z.string().nullable().optional(),
|
first_name: z.string().nullable().optional(),
|
||||||
last_name: z.string().nullable().optional(),
|
last_name: z.string().nullable().optional(),
|
||||||
phone: z.string().nullable().optional(),
|
phone: z.string().nullable().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AdminCreateCustomerAddress = z.object({
|
export const AdminCreateCustomerAddress = z.object({
|
||||||
@@ -77,7 +79,7 @@ export const AdminCreateCustomerAddress = z.object({
|
|||||||
|
|
||||||
export const AdminUpdateCustomerAddress = AdminCreateCustomerAddress
|
export const AdminUpdateCustomerAddress = AdminCreateCustomerAddress
|
||||||
|
|
||||||
export const AdminCustomerAdressesParams = createFindParams({
|
export const AdminCustomerAddressesParams = createFindParams({
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
}).merge(
|
}).merge(
|
||||||
|
|||||||
Reference in new issue
Block a user