diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index c78a3b87fcc6..2471a5cb64b3 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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", diff --git a/packages/admin-next/dashboard/src/components/forms/metadata/index.ts b/packages/admin-next/dashboard/src/components/forms/metadata/index.ts new file mode 100644 index 000000000000..307c08c118b7 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/metadata/index.ts @@ -0,0 +1 @@ +export * from "./metadata" diff --git a/packages/admin-next/dashboard/src/components/forms/metadata/metadata.tsx b/packages/admin-next/dashboard/src/components/forms/metadata/metadata.tsx new file mode 100644 index 000000000000..6bc3ba7fbc57 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/metadata/metadata.tsx @@ -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 +} + +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 ( + + + { + updateKey(e.currentTarget.value) + }} + /> + + + { + updateValue(e.currentTarget.value) + }} + /> + {!isLast && ( + + )} + + + ) +} + +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 ( +
+ + {t("fields.metadata")} + + + + + + + + + + {metadataWatch.map((field, index) => { + return ( + + ) + })} + +
+ + {t("fields.key")} + + + + {t("fields.value")} + +
+ {!!ignoredKeys.length && ( + + {t("metadata.warnings.ignoredKeys", { keys: ignoredKeys.join(",") })} + + )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/lib/metadata.ts b/packages/admin-next/dashboard/src/lib/metadata.ts new file mode 100644 index 000000000000..1b252f48898d --- /dev/null +++ b/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 | 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 => { + 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) +} diff --git a/packages/admin-next/dashboard/src/lib/validation.ts b/packages/admin-next/dashboard/src/lib/validation.ts index f6ef30d1910c..b0972576bc5d 100644 --- a/packages/admin-next/dashboard/src/lib/validation.ts +++ b/packages/admin-next/dashboard/src/lib/validation.ts @@ -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(), + }) +) diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx index c0fe4e4d282d..3bec19bfffe2 100644 --- a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx @@ -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) => { ) }} /> + diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx index bbf51810a24c..6eca5eb4cff3 100644 --- a/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customer-edit/customer-edit.tsx @@ -18,7 +18,7 @@ export const CustomerEdit = () => { return ( - {t("customers.editCustomer")} + {t("customers.edit.header")} {!isLoading && customer && } diff --git a/packages/admin-ui/ui/src/domain/customers/groups/customer-group-modal.tsx b/packages/admin-ui/ui/src/domain/customers/groups/customer-group-modal.tsx index f537a2556302..7947cc78b1fd 100644 --- a/packages/admin-ui/ui/src/domain/customers/groups/customer-group-modal.tsx +++ b/packages/admin-ui/ui/src/domain/customers/groups/customer-group-modal.tsx @@ -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 diff --git a/packages/medusa/src/api-v2/admin/customers/middlewares.ts b/packages/medusa/src/api-v2/admin/customers/middlewares.ts index 9819920cc0de..0306ba0b0e57 100644 --- a/packages/medusa/src/api-v2/admin/customers/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customers/middlewares.ts @@ -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 ), ], diff --git a/packages/medusa/src/api-v2/admin/customers/query-config.ts b/packages/medusa/src/api-v2/admin/customers/query-config.ts index c29894978365..842e87e12bf6 100644 --- a/packages/medusa/src/api-v2/admin/customers/query-config.ts +++ b/packages/medusa/src/api-v2/admin/customers/query-config.ts @@ -5,6 +5,7 @@ export const defaultAdminCustomerFields = [ "last_name", "email", "phone", + "metadata", "has_account", "created_by", "created_at", diff --git a/packages/medusa/src/api-v2/admin/customers/validators.ts b/packages/medusa/src/api-v2/admin/customers/validators.ts index 84172a933569..7b381b5fd305 100644 --- a/packages/medusa/src/api-v2/admin/customers/validators.ts +++ b/packages/medusa/src/api-v2/admin/customers/validators.ts @@ -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(