Skip to content

Commit

Permalink
feat(dashboard): metadata component (#7117)
Browse files Browse the repository at this point in the history
**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
  • Loading branch information
fPolic committed May 2, 2024
1 parent ea1d926 commit 155e276
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 5 deletions.
Expand Up @@ -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",
Expand Down
@@ -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 changes: 84 additions & 0 deletions packages/admin-next/dashboard/src/lib/metadata.ts
@@ -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>)
}
13 changes: 13 additions & 0 deletions packages/admin-next/dashboard/src/lib/validation.ts
Expand Up @@ -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(),
})
)
Expand Up @@ -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"]
Expand All @@ -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) => {
Expand All @@ -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),
})
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -156,6 +165,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
)
}}
/>
<Metadata form={form} />
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
Expand Down
Expand Up @@ -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>
Expand Down
Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/medusa/src/api-v2/admin/customers/middlewares.ts
Expand Up @@ -3,7 +3,7 @@ import * as QueryConfig from "./query-config"
import {
AdminCreateCustomer,
AdminCreateCustomerAddress,
AdminCustomerAdressesParams,
AdminCustomerAddressesParams,
AdminCustomerParams,
AdminCustomersParams,
AdminUpdateCustomer,
Expand Down Expand Up @@ -100,7 +100,7 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/admin/customers/:id/addresses",
middlewares: [
validateAndTransformQuery(
AdminCustomerAdressesParams,
AdminCustomerAddressesParams,
QueryConfig.listAddressesTransformQueryConfig
),
],
Expand Down
1 change: 1 addition & 0 deletions packages/medusa/src/api-v2/admin/customers/query-config.ts
Expand Up @@ -5,6 +5,7 @@ export const defaultAdminCustomerFields = [
"last_name",
"email",
"phone",
"metadata",
"has_account",
"created_by",
"created_at",
Expand Down
4 changes: 3 additions & 1 deletion packages/medusa/src/api-v2/admin/customers/validators.ts
Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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(
Expand Down

0 comments on commit 155e276

Please sign in to comment.