Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard): metadata component #7117

Merged
merged 8 commits into from May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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