From b5a44ef6b1b33f44e18dc521894a8bb904d68133 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 11 Jul 2024 15:54:59 +0200 Subject: [PATCH] feat(dashboard,medusa): Add updated Metadata Form (#8084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What** - Adds new Metadata form component. - Adds the Metadata section as an option to the Page layouts Skærmbillede 2024-07-11 kl  11 34 06 Skærmbillede 2024-07-11 kl  11 34 33 **Note** - When Metadata contains non-primitive data, we disable those rows, and show a placeholder value, a tooltip and an alert describing that the row can be edited through the API. I want to add a JSON editor to allow editing these things in admin, but awaiting approval on that. - This PR only adds the new form to a couple of pages, to keep the PR light, especially since metadata is not implemented correctly in all validators so also needs some changes to the core. This still show some examples of how its used with the new Page layout components. Will follow up with more pages in future PRs. - We try to convert the inputs to the best fitting primitive, so if a user types "true" then we save the value as a boolean, "130" as number, "testing" as a string, etc. --- .../json-view-section/json-view-section.tsx | 8 +- .../common/metadata-section/index.ts | 1 + .../metadata-section/metadata-section.tsx | 49 +++ .../components/common/skeleton/skeleton.tsx | 96 ++++ .../components/forms/metadata-form/index.ts | 1 + .../forms/metadata-form/metadata-form.tsx | 415 ++++++++++++++++++ .../src/components/forms/metadata/index.ts | 1 - .../components/forms/metadata/metadata.tsx | 157 ------- .../single-column-page/single-column-page.tsx | 27 +- .../pages/two-column-page/two-column-page.tsx | 36 +- .../src/components/layout/pages/types.ts | 1 + .../dashboard/src/hooks/api/customers.tsx | 11 +- .../dashboard/src/hooks/api/products.tsx | 22 +- .../dashboard/src/i18n/translations/en.json | 29 +- .../admin-next/dashboard/src/lib/metadata.ts | 84 ---- .../providers/router-provider/route-map.tsx | 17 + .../customer-group-detail.tsx | 35 +- .../customer-metadata.tsx | 26 ++ .../customer-group-metadata/index.ts | 1 + .../customer-detail/customer-detail.tsx | 36 +- .../edit-customer-form/edit-customer-form.tsx | 10 - .../customer-metadata/customer-metadata.tsx | 23 + .../customers/customer-metadata/index.ts | 1 + .../use-variant-table-columns.tsx | 8 +- .../product-detail/product-detail.tsx | 87 ++-- .../routes/products/product-metadata/index.ts | 1 + .../product-metadata/product-metadata.tsx | 24 + .../api/admin/customer-groups/validators.ts | 2 +- 28 files changed, 823 insertions(+), 386 deletions(-) create mode 100644 packages/admin-next/dashboard/src/components/common/metadata-section/index.ts create mode 100644 packages/admin-next/dashboard/src/components/common/metadata-section/metadata-section.tsx create mode 100644 packages/admin-next/dashboard/src/components/forms/metadata-form/index.ts create mode 100644 packages/admin-next/dashboard/src/components/forms/metadata-form/metadata-form.tsx delete mode 100644 packages/admin-next/dashboard/src/components/forms/metadata/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/forms/metadata/metadata.tsx delete mode 100644 packages/admin-next/dashboard/src/lib/metadata.ts create mode 100644 packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/customer-metadata.tsx create mode 100644 packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-metadata/customer-metadata.tsx create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-metadata/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/products/product-metadata/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/products/product-metadata/product-metadata.tsx diff --git a/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx b/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx index 6243edb519..ba157b5855 100644 --- a/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx +++ b/packages/admin-next/dashboard/src/components/common/json-view-section/json-view-section.tsx @@ -1,5 +1,5 @@ import { - ArrowsPointingOut, + ArrowUpRightOnBox, Check, SquareTwoStack, TriangleDownMini, @@ -30,7 +30,7 @@ export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
{t("json.header")} - + {t("json.numberOfKeys", { count: numberOfKeys, })} @@ -41,9 +41,9 @@ export const JsonViewSection = ({ data }: JsonViewSectionProps) => { - + diff --git a/packages/admin-next/dashboard/src/components/common/metadata-section/index.ts b/packages/admin-next/dashboard/src/components/common/metadata-section/index.ts new file mode 100644 index 0000000000..2281a512a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/metadata-section/index.ts @@ -0,0 +1 @@ +export * from "./metadata-section" diff --git a/packages/admin-next/dashboard/src/components/common/metadata-section/metadata-section.tsx b/packages/admin-next/dashboard/src/components/common/metadata-section/metadata-section.tsx new file mode 100644 index 0000000000..4786bb31d0 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/metadata-section/metadata-section.tsx @@ -0,0 +1,49 @@ +import { ArrowUpRightOnBox } from "@medusajs/icons" +import { Badge, Container, Heading, IconButton } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +type MetadataSectionProps = { + data: TData + href?: string +} + +export const MetadataSection = ({ + data, + href = "metadata/edit", +}: MetadataSectionProps) => { + const { t } = useTranslation() + + if (!data) { + return null + } + + if (!("metadata" in data)) { + return null + } + + const numberOfKeys = data.metadata ? Object.keys(data.metadata).length : 0 + + return ( + +
+ {t("metadata.header")} + + {t("metadata.numberOfKeys", { + count: numberOfKeys, + })} + +
+ + + + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx b/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx index db31eaed4e..90671af690 100644 --- a/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx +++ b/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx @@ -229,3 +229,99 @@ export const JsonViewSectionSkeleton = () => { ) } + +type SingleColumnPageSkeletonProps = { + sections?: number + showJSON?: boolean + showMetadata?: boolean +} + +export const SingleColumnPageSkeleton = ({ + sections = 2, + showJSON = false, + showMetadata = false, +}: SingleColumnPageSkeletonProps) => { + return ( +
+ {Array.from({ length: sections }, (_, i) => i).map((section) => { + return ( + + ) + })} + {showMetadata && } + {showJSON && } +
+ ) +} + +type TwoColumnPageSkeletonProps = { + mainSections?: number + sidebarSections?: number + showJSON?: boolean + showMetadata?: boolean +} + +export const TwoColumnPageSkeleton = ({ + mainSections = 2, + sidebarSections = 1, + showJSON = false, + showMetadata = true, +}: TwoColumnPageSkeletonProps) => { + const showExtraData = showJSON || showMetadata + + return ( +
+
+
+ {Array.from({ length: mainSections }, (_, i) => i).map((section) => { + return ( + + ) + })} + {showExtraData && ( +
+ {showMetadata && ( + + )} + {showJSON && } +
+ )} +
+
+ {Array.from({ length: sidebarSections }, (_, i) => i).map( + (section) => { + return ( + + ) + } + )} + {showExtraData && ( +
+ {showMetadata && ( + + )} + {showJSON && } +
+ )} +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/forms/metadata-form/index.ts b/packages/admin-next/dashboard/src/components/forms/metadata-form/index.ts new file mode 100644 index 0000000000..77c403fc70 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/metadata-form/index.ts @@ -0,0 +1 @@ +export * from "./metadata-form" diff --git a/packages/admin-next/dashboard/src/components/forms/metadata-form/metadata-form.tsx b/packages/admin-next/dashboard/src/components/forms/metadata-form/metadata-form.tsx new file mode 100644 index 0000000000..7529453ce3 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/metadata-form/metadata-form.tsx @@ -0,0 +1,415 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + Button, + DropdownMenu, + Heading, + IconButton, + clx, + toast, +} from "@medusajs/ui" +import { useFieldArray, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" + +import { + ArrowDownMini, + ArrowUpMini, + EllipsisVertical, + Trash, +} from "@medusajs/icons" +import { FetchError } from "@medusajs/js-sdk" +import { ComponentPropsWithoutRef, forwardRef, useRef } from "react" +import { ConditionalTooltip } from "../../common/conditional-tooltip" +import { Form } from "../../common/form" +import { InlineTip } from "../../common/inline-tip" +import { Skeleton } from "../../common/skeleton" +import { RouteDrawer, useRouteModal } from "../../modals" + +type MetaDataSubmitHook = ( + params: { metadata?: Record | null }, + callbacks: { onSuccess: () => void; onError: (error: FetchError) => void } +) => Promise + +type MetadataFormProps = { + metadata?: Record | null + hook: MetaDataSubmitHook + isPending: boolean + isMutating: boolean +} + +const MetadataFieldSchema = z.object({ + key: z.string(), + disabled: z.boolean().optional(), + value: z.any(), +}) + +const MetadataSchema = z.object({ + metadata: z.array(MetadataFieldSchema), +}) + +export const MetadataForm = (props: MetadataFormProps) => { + const { t } = useTranslation() + const { isPending, ...innerProps } = props + + return ( + + + + {t("metadata.edit.header")} + + + {t("metadata.edit.description")} + + + {isPending ? : } + + ) +} + +const METADATA_KEY_LABEL_ID = "metadata-form-key-label" +const METADATA_VALUE_LABEL_ID = "metadata-form-value-label" + +const InnerForm = ({ + metadata, + hook, + isMutating, +}: Omit, "isPending">) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const deletedOriginalRows = useRef([]) + const hasUneditableRows = getHasUneditableRows(metadata) + + const form = useForm>({ + defaultValues: { + metadata: getDefaultValues(metadata), + }, + resolver: zodResolver(MetadataSchema), + }) + + const handleSubmit = form.handleSubmit(async (data) => { + const parsedData = parseValues(data) + + await hook( + { + metadata: parsedData, + }, + { + onSuccess: () => { + toast.success(t("metadata.edit.successToast")) + handleSuccess() + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + }) + + const { fields, insert, remove } = useFieldArray({ + control: form.control, + name: "metadata", + }) + + function deleteRow(index: number) { + remove(index) + } + + function insertRow(index: number, position: "above" | "below") { + insert(index + (position === "above" ? 0 : 1), { + key: "", + value: "", + disabled: false, + }) + } + + return ( + +
+ +
+
+
+ +
+
+ +
+
+ {fields.map((field, index) => { + const isDisabled = field.disabled || false + let placeholder = "-" + + if (typeof field.value === "object") { + placeholder = "{ ... }" + } + + if (Array.isArray(field.value)) { + placeholder = "[ ... ]" + } + + return ( + +
+
+ { + return ( + + + + + + ) + }} + /> + { + return ( + + + + + + ) + }} + /> +
+ + + + + + + + insertRow(index, "above")} + > + + {t("metadata.edit.actions.insertRowAbove")} + + insertRow(index, "below")} + > + + {t("metadata.edit.actions.insertRowBelow")} + + + deleteRow(index)} + > + + {t("metadata.edit.actions.deleteRow")} + + + +
+
+ ) + })} +
+ {hasUneditableRows && ( + + {t("metadata.edit.complexRow.description")} + + )} +
+ +
+ + + + +
+
+
+
+ ) +} + +const GridInput = forwardRef< + HTMLInputElement, + ComponentPropsWithoutRef<"input"> +>(({ className, ...props }, ref) => { + return ( + + ) +}) +GridInput.displayName = "MetadataForm.GridInput" + +const PlaceholderInner = () => { + return ( +
+ + + + +
+ + +
+
+
+ ) +} + +const EDITABLE_TYPES = ["string", "number", "boolean"] + +function getDefaultValues( + metadata?: Record | null +): z.infer[] { + if (!metadata || !Object.keys(metadata).length) { + return [ + { + key: "", + value: "", + disabled: false, + }, + ] + } + + return Object.entries(metadata).map(([key, value]) => { + if (!EDITABLE_TYPES.includes(typeof value)) { + return { + key, + value: value, + disabled: true, + } + } + + let stringValue = value + + if (typeof value !== "string") { + stringValue = JSON.stringify(value) + } + + return { + key, + value: stringValue, + original_key: key, + } + }) +} + +function parseValues( + values: z.infer +): Record | null { + const metadata = values.metadata + + const isEmpty = + !metadata.length || + (metadata.length === 1 && !metadata[0].key && !metadata[0].value) + + if (isEmpty) { + return null + } + + const update: Record = {} + + metadata.forEach((field) => { + let key = field.key + let value = field.value + const disabled = field.disabled + + if (!key || !value) { + return + } + + if (disabled) { + update[key] = value + return + } + + key = key.trim() + value = value.trim() + + // We try to cast the value to a boolean or number if possible + if (value === "true") { + update[key] = true + } else if (value === "false") { + update[key] = false + } else { + const parsedNumber = parseFloat(value) + if (!isNaN(parsedNumber)) { + update[key] = parsedNumber + } else { + update[key] = value + } + } + }) + + return update +} + +function getHasUneditableRows(metadata?: Record | null) { + if (!metadata) { + return false + } + + return Object.values(metadata).some( + (value) => !EDITABLE_TYPES.includes(typeof value) + ) +} diff --git a/packages/admin-next/dashboard/src/components/forms/metadata/index.ts b/packages/admin-next/dashboard/src/components/forms/metadata/index.ts deleted file mode 100644 index 307c08c118..0000000000 --- a/packages/admin-next/dashboard/src/components/forms/metadata/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 6bc3ba7fbc..0000000000 --- a/packages/admin-next/dashboard/src/components/forms/metadata/metadata.tsx +++ /dev/null @@ -1,157 +0,0 @@ -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/components/layout/pages/single-column-page/single-column-page.tsx b/packages/admin-next/dashboard/src/components/layout/pages/single-column-page/single-column-page.tsx index 2f4b1da587..fc2758ef02 100644 --- a/packages/admin-next/dashboard/src/components/layout/pages/single-column-page/single-column-page.tsx +++ b/packages/admin-next/dashboard/src/components/layout/pages/single-column-page/single-column-page.tsx @@ -1,13 +1,27 @@ import { Outlet } from "react-router-dom" import { JsonViewSection } from "../../../common/json-view-section" +import { MetadataSection } from "../../../common/metadata-section" import { PageProps } from "../types" export const SingleColumnPage = ({ children, widgets, + /** + * Data of the page which is passed to Widgets, JSON view, and Metadata view. + */ data, - hasOutlet, + /** + * Whether the page should render an outlet for children routes. Defaults to true. + */ + hasOutlet = true, + /** + * Whether to show JSON view of the data. Defaults to false. + */ showJSON, + /** + * Whether to show metadata view of the data. Defaults to false. + */ + showMetadata, }: PageProps) => { const { before, after } = widgets const widgetProps = { data } @@ -22,6 +36,16 @@ export const SingleColumnPage = ({ showJSON = false } + if (showMetadata && !data) { + if (process.env.NODE_ENV === "development") { + console.warn( + "`showMetadata` is true but no data is provided. To display metadata, provide data prop." + ) + } + + showMetadata = false + } + return (
{before.widgets.map((w, i) => { @@ -31,6 +55,7 @@ export const SingleColumnPage = ({ {after.widgets.map((w, i) => { return })} + {showMetadata && } {showJSON && } {hasOutlet && }
diff --git a/packages/admin-next/dashboard/src/components/layout/pages/two-column-page/two-column-page.tsx b/packages/admin-next/dashboard/src/components/layout/pages/two-column-page/two-column-page.tsx index 99d4e2cc04..d891150182 100644 --- a/packages/admin-next/dashboard/src/components/layout/pages/two-column-page/two-column-page.tsx +++ b/packages/admin-next/dashboard/src/components/layout/pages/two-column-page/two-column-page.tsx @@ -2,6 +2,7 @@ import { clx } from "@medusajs/ui" import { Children, ComponentPropsWithoutRef } from "react" import { Outlet } from "react-router-dom" import { JsonViewSection } from "../../../common/json-view-section" +import { MetadataSection } from "../../../common/metadata-section" import { PageProps, WidgetImport, WidgetProps } from "../types" interface TwoColumnWidgetProps extends WidgetProps { @@ -20,13 +21,17 @@ const Root = ({ */ widgets, /** - * Data to be passed to widgets and the JSON view. + * Data to be passed to widgets, JSON view, and Metadata view. */ data, /** - * Whether to show JSON view of the data. Defaults to true. + * Whether to show JSON view of the data. Defaults to false. */ showJSON = false, + /** + * Whether to show metadata view of the data. Defaults to false. + */ + showMetadata = false, /** * Whether to render an outlet for children routes. Defaults to true. */ @@ -45,6 +50,16 @@ const Root = ({ showJSON = false } + if (showMetadata && !data) { + if (process.env.NODE_ENV === "development") { + console.warn( + "`showMetadata` is true but no data is provided. To display metadata, provide data prop." + ) + } + + showMetadata = false + } + const childrenArray = Children.toArray(children) if (childrenArray.length !== 2) { @@ -52,9 +67,10 @@ const Root = ({ } const [main, sidebar] = childrenArray + const showExtraData = showJSON || showMetadata return ( -
+
{before.widgets.map((w, i) => { return })} @@ -64,9 +80,10 @@ const Root = ({ {after.widgets.map((w, i) => { return })} - {showJSON && ( -
- + {showExtraData && ( +
+ {showMetadata && } + {showJSON && }
)}
@@ -78,9 +95,10 @@ const Root = ({ {sideAfter.widgets.map((w, i) => { return })} - {showJSON && ( -
- + {showExtraData && ( +
+ {showMetadata && } + {showJSON && }
)}
diff --git a/packages/admin-next/dashboard/src/components/layout/pages/types.ts b/packages/admin-next/dashboard/src/components/layout/pages/types.ts index 8cbaf9165e..ef602359df 100644 --- a/packages/admin-next/dashboard/src/components/layout/pages/types.ts +++ b/packages/admin-next/dashboard/src/components/layout/pages/types.ts @@ -18,5 +18,6 @@ export interface PageProps { widgets: WidgetProps data?: TData showJSON?: boolean + showMetadata?: boolean hasOutlet?: boolean } diff --git a/packages/admin-next/dashboard/src/hooks/api/customers.tsx b/packages/admin-next/dashboard/src/hooks/api/customers.tsx index adfbb1e372..ef99260459 100644 --- a/packages/admin-next/dashboard/src/hooks/api/customers.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/customers.tsx @@ -1,3 +1,4 @@ +import { FetchError } from "@medusajs/js-sdk" import { DeleteResponse, HttpTypes, PaginatedResponse } from "@medusajs/types" import { QueryKey, @@ -19,7 +20,7 @@ export const useCustomer = ( options?: Omit< UseQueryOptions< { customer: HttpTypes.AdminCustomer }, - Error, + FetchError, { customer: HttpTypes.AdminCustomer }, QueryKey >, @@ -40,7 +41,7 @@ export const useCustomers = ( options?: Omit< UseQueryOptions< PaginatedResponse<{ customers: HttpTypes.AdminCustomer[] }>, - Error, + FetchError, PaginatedResponse<{ customers: HttpTypes.AdminCustomer[] }>, QueryKey >, @@ -59,7 +60,7 @@ export const useCustomers = ( export const useCreateCustomer = ( options?: UseMutationOptions< { customer: HttpTypes.AdminCustomer }, - Error, + FetchError, HttpTypes.AdminCreateCustomer > ) => { @@ -77,7 +78,7 @@ export const useUpdateCustomer = ( id: string, options?: UseMutationOptions< { customer: HttpTypes.AdminCustomer }, - Error, + FetchError, HttpTypes.AdminUpdateCustomer > ) => { @@ -95,7 +96,7 @@ export const useUpdateCustomer = ( export const useDeleteCustomer = ( id: string, - options?: UseMutationOptions, Error, void> + options?: UseMutationOptions, FetchError, void> ) => { return useMutation({ mutationFn: () => sdk.admin.customer.delete(id), diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx index b9b6ea2c50..f66faf8386 100644 --- a/packages/admin-next/dashboard/src/hooks/api/products.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx @@ -268,15 +268,14 @@ export const useProducts = ( export const useCreateProduct = ( options?: UseMutationOptions< - { product: HttpTypes.AdminProduct }, - Error, + HttpTypes.AdminProductResponse, + FetchError, HttpTypes.AdminCreateProduct > ) => { return useMutation({ - mutationFn: (payload: HttpTypes.AdminCreateProduct) => - sdk.admin.product.create(payload), - onSuccess: (data: any, variables: any, context: any) => { + mutationFn: (payload) => sdk.admin.product.create(payload), + onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) // if `manage_inventory` is true on created variants that will create inventory items automatically queryClient.invalidateQueries({ @@ -290,12 +289,15 @@ export const useCreateProduct = ( export const useUpdateProduct = ( id: string, - options?: UseMutationOptions + options?: UseMutationOptions< + HttpTypes.AdminProductResponse, + FetchError, + HttpTypes.AdminUpdateProduct + > ) => { return useMutation({ - mutationFn: (payload: HttpTypes.AdminUpdateProduct) => - sdk.admin.product.update(id, payload), - onSuccess: (data: any, variables: any, context: any) => { + mutationFn: (payload) => sdk.admin.product.update(id, payload), + onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) queryClient.invalidateQueries({ queryKey: productsQueryKeys.detail(id) }) @@ -309,7 +311,7 @@ export const useDeleteProduct = ( id: string, options?: UseMutationOptions< HttpTypes.AdminProductDeleteResponse, - Error, + FetchError, void > ) => { diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 2a62353e5a..c153077b12 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -62,6 +62,30 @@ "description": "View the JSON data for this object." } }, + "metadata": { + "header": "Metadata", + "numberOfKeys_one": "{{count}} key", + "numberOfKeys_other": "{{count}} keys", + "edit": { + "header": "Edit Metadata", + "description": "Edit the metadata for this object.", + "successToast": "Metadata was successfully updated.", + "actions": { + "insertRowAbove": "Insert row above", + "insertRowBelow": "Insert row below", + "deleteRow": "Delete row" + }, + "labels": { + "key": "Key", + "value": "Value" + }, + "complexRow": { + "label": "Some rows are disabled", + "description": "This object contains non-primitive metadata, such as arrays or objects, that can't be edited here. To edit the disabled rows, use the API directly.", + "tooltip": "This row is disabled because it contains non-primitive data." + } + } + }, "validation": { "mustBeInt": "The value must be a whole number.", "mustBePositive": "The value must be a positive number." @@ -2184,11 +2208,6 @@ "draft": "Draft", "values": "Values" }, - "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/lib/metadata.ts b/packages/admin-next/dashboard/src/lib/metadata.ts deleted file mode 100644 index 1b252f4889..0000000000 --- a/packages/admin-next/dashboard/src/lib/metadata.ts +++ /dev/null @@ -1,84 +0,0 @@ -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/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index 0dac6873f8..f8b5327bcd 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -114,6 +114,11 @@ export const RouteMap: RouteObject[] = [ lazy: () => import("../../routes/products/product-create-variant"), }, + { + path: "metadata/edit", + lazy: () => + import("../../routes/products/product-metadata"), + }, ], }, { @@ -427,6 +432,11 @@ export const RouteMap: RouteObject[] = [ "../../routes/customers/customers-add-customer-group" ), }, + { + path: "metadata/edit", + lazy: () => + import("../../routes/customers/customer-metadata"), + }, ], }, ], @@ -475,6 +485,13 @@ export const RouteMap: RouteObject[] = [ "../../routes/customer-groups/customer-group-add-customers" ), }, + { + path: "metadata/edit", + lazy: () => + import( + "../../routes/customer-groups/customer-group-metadata" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-detail/customer-group-detail.tsx b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-detail/customer-group-detail.tsx index 2b8cd46464..7191705415 100644 --- a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-detail/customer-group-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-detail/customer-group-detail.tsx @@ -1,6 +1,6 @@ -import { Outlet, useLoaderData, useParams } from "react-router-dom" +import { useLoaderData, useParams } from "react-router-dom" -import { JsonViewSection } from "../../../components/common/json-view-section" +import { SingleColumnPage } from "../../../components/layout/pages" import { useCustomerGroup } from "../../../hooks/api/customer-groups" import { CustomerGroupCustomerSection } from "./components/customer-group-customer-section" import { CustomerGroupGeneralSection } from "./components/customer-group-general-section" @@ -8,6 +8,7 @@ import { customerGroupLoader } from "./loader" import after from "virtual:medusa/widgets/customer_group/details/after" import before from "virtual:medusa/widgets/customer_group/details/before" +import { SingleColumnPageSkeleton } from "../../../components/common/skeleton" export const CustomerGroupDetail = () => { const initialData = useLoaderData() as Awaited< @@ -24,7 +25,7 @@ export const CustomerGroupDetail = () => { ) if (isLoading || !customer_group) { - return
Loading...
+ return } if (isError) { @@ -32,25 +33,17 @@ export const CustomerGroupDetail = () => { } return ( -
- {before.widgets.map((w, i) => { - return ( -
- -
- ) - })} + - {after.widgets.map((w, i) => { - return ( -
- -
- ) - })} - - -
+ ) } diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/customer-metadata.tsx b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/customer-metadata.tsx new file mode 100644 index 0000000000..375a39468f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/customer-metadata.tsx @@ -0,0 +1,26 @@ +import { useParams } from "react-router-dom" +import { MetadataForm } from "../../../components/forms/metadata-form" +import { + useCustomerGroup, + useUpdateCustomerGroup, +} from "../../../hooks/api/customer-groups" + +export const CustomerGroupMetadata = () => { + const { id } = useParams() + + const { customer_group, isPending, isError, error } = useCustomerGroup(id!) + const { mutateAsync, isPending: isMutating } = useUpdateCustomerGroup(id!) + + if (isError) { + throw error + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/index.ts b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/index.ts new file mode 100644 index 0000000000..f61cc95629 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/customer-group-metadata/index.ts @@ -0,0 +1 @@ +export { CustomerGroupMetadata as Component } from "./customer-metadata" diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-detail/customer-detail.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-detail/customer-detail.tsx index 6a5575f6b7..6c13fe989c 100644 --- a/packages/admin-next/dashboard/src/routes/customers/customer-detail/customer-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/customers/customer-detail/customer-detail.tsx @@ -1,6 +1,7 @@ -import { Outlet, useLoaderData, useParams } from "react-router-dom" +import { useLoaderData, useParams } from "react-router-dom" -import { JsonViewSection } from "../../../components/common/json-view-section" +import { SingleColumnPageSkeleton } from "../../../components/common/skeleton" +import { SingleColumnPage } from "../../../components/layout/pages" import { useCustomer } from "../../../hooks/api/customers" import { CustomerGeneralSection } from "./components/customer-general-section" import { CustomerGroupSection } from "./components/customer-group-section" @@ -20,7 +21,7 @@ export const CustomerDetail = () => { }) if (isLoading || !customer) { - return
Loading...
+ return } if (isError) { @@ -28,28 +29,21 @@ export const CustomerDetail = () => { } return ( -
- {before.widgets.map((w, i) => { - return ( -
- -
- ) - })} + {/* // TODO: re-add when order endpoints are added to api-v2 */} - {after.widgets.map((w, i) => { - return ( -
- -
- ) - })} - - -
+ ) } diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx index bf4fe3ebc3..7637ed13c0 100644 --- a/packages/admin-next/dashboard/src/routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx +++ b/packages/admin-next/dashboard/src/routes/customers/customer-edit/components/edit-customer-form/edit-customer-form.tsx @@ -6,17 +6,11 @@ import { useTranslation } from "react-i18next" import * as zod from "zod" import { ConditionalTooltip } from "../../../../../components/common/conditional-tooltip/index.ts" import { Form } from "../../../../../components/common/form/index.ts" -import { Metadata } from "../../../../../components/forms/metadata/index.ts" import { RouteDrawer, useRouteModal, } from "../../../../../components/modals/index.ts" import { useUpdateCustomer } from "../../../../../hooks/api/customers.tsx" -import { - formValuesToMetadata, - metadataToFormValues, -} from "../../../../../lib/metadata.ts" -import { metadataFormSchema } from "../../../../../lib/validation.ts" type EditCustomerFormProps = { customer: HttpTypes.AdminCustomer @@ -28,7 +22,6 @@ 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) => { @@ -42,7 +35,6 @@ 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), }) @@ -57,7 +49,6 @@ 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 }) => { @@ -161,7 +152,6 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => { ) }} /> -
diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-metadata/customer-metadata.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-metadata/customer-metadata.tsx new file mode 100644 index 0000000000..b66036e083 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-metadata/customer-metadata.tsx @@ -0,0 +1,23 @@ +import { useParams } from "react-router-dom" +import { MetadataForm } from "../../../components/forms/metadata-form" +import { useCustomer, useUpdateCustomer } from "../../../hooks/api/customers" + +export const CustomerMetadata = () => { + const { id } = useParams() + + const { customer, isPending, isError, error } = useCustomer(id!) + const { mutateAsync, isPending: isMutating } = useUpdateCustomer(id!) + + if (isError) { + throw error + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-metadata/index.ts b/packages/admin-next/dashboard/src/routes/customers/customer-metadata/index.ts new file mode 100644 index 0000000000..738d499801 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-metadata/index.ts @@ -0,0 +1 @@ +export { CustomerMetadata as Component } from "./customer-metadata" diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx index 9813776ebe..a07fb7c429 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx @@ -1,9 +1,9 @@ import { Buildings, Component, PencilSquare, Trash } from "@medusajs/icons" -import { Badge, usePrompt, clx } from "@medusajs/ui" -import { createColumnHelper } from "@tanstack/react-table" import { HttpTypes, InventoryItemDTO } from "@medusajs/types" -import { useTranslation } from "react-i18next" +import { Badge, clx, usePrompt } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" +import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../components/common/action-menu" import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell" @@ -128,7 +128,7 @@ export const useProductVariantTableColumns = ( {variantOpt.value} diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx index 3e03aba316..cfec4f335b 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx @@ -1,6 +1,6 @@ -import { Outlet, useLoaderData, useParams } from "react-router-dom" +import { useLoaderData, useParams } from "react-router-dom" -import { JsonViewSection } from "../../../components/common/json-view-section" +import { TwoColumnPage } from "../../../components/layout/pages" import { useProduct } from "../../../hooks/api/products" import { ProductAttributeSection } from "./components/product-attribute-section" import { ProductGeneralSection } from "./components/product-general-section" @@ -16,6 +16,7 @@ import after from "virtual:medusa/widgets/product/details/after" import before from "virtual:medusa/widgets/product/details/before" import sideAfter from "virtual:medusa/widgets/product/details/side/after" import sideBefore from "virtual:medusa/widgets/product/details/side/before" +import { TwoColumnPageSkeleton } from "../../../components/common/skeleton" export const ProductDetail = () => { const initialData = useLoaderData() as Awaited< @@ -32,7 +33,14 @@ export const ProductDetail = () => { ) if (isLoading || !product) { - return
Loading...
+ return ( + + ) } if (isError) { @@ -40,55 +48,28 @@ export const ProductDetail = () => { } return ( -
- {before.widgets.map((w, i) => { - return ( -
- -
- ) - })} -
-
- - - - - {after.widgets.map((w, i) => { - return ( -
- -
- ) - })} -
- -
-
-
- {sideBefore.widgets.map((w, i) => { - return ( -
- -
- ) - })} - - - - {sideAfter.widgets.map((w, i) => { - return ( -
- -
- ) - })} -
- -
-
-
- -
+ + + + + + + + + + + + + ) } diff --git a/packages/admin-next/dashboard/src/routes/products/product-metadata/index.ts b/packages/admin-next/dashboard/src/routes/products/product-metadata/index.ts new file mode 100644 index 0000000000..4b00b89ba3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-metadata/index.ts @@ -0,0 +1 @@ +export { ProductMetadata as Component } from "./product-metadata" diff --git a/packages/admin-next/dashboard/src/routes/products/product-metadata/product-metadata.tsx b/packages/admin-next/dashboard/src/routes/products/product-metadata/product-metadata.tsx new file mode 100644 index 0000000000..cd15d44ecd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-metadata/product-metadata.tsx @@ -0,0 +1,24 @@ +import { useParams } from "react-router-dom" +import { MetadataForm } from "../../../components/forms/metadata-form/metadata-form" +import { useProduct, useUpdateProduct } from "../../../hooks/api" + +export const ProductMetadata = () => { + const { id } = useParams() + + const { product, isPending, isError, error } = useProduct(id!) + + const { mutateAsync, isPending: isMutating } = useUpdateProduct(product.id!) + + if (isError) { + throw error + } + + return ( + + ) +} diff --git a/packages/medusa/src/api/admin/customer-groups/validators.ts b/packages/medusa/src/api/admin/customer-groups/validators.ts index 6c44f48afd..f36b0ea433 100644 --- a/packages/medusa/src/api/admin/customer-groups/validators.ts +++ b/packages/medusa/src/api/admin/customer-groups/validators.ts @@ -65,6 +65,6 @@ export type AdminUpdateCustomerGroupType = z.infer< typeof AdminUpdateCustomerGroup > export const AdminUpdateCustomerGroup = z.object({ - name: z.string(), + name: z.string().optional(), metadata: z.record(z.unknown()).nullish(), })