feat(admin-ui): metadata for categories in admin ui and refactor to react-hook-form (#5697)

* WIP

* add metadata to category add and edit ui

* remove console.log

* lint

* remove console.log

* requested changes getDefaultCategoryValues util

* change placeholder and wrap handleSubmit

* Update edit-product-category.tsx

---------

Co-authored-by: lulu <lucjan.grzesik@gmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2023-12-07 10:51:21 +01:00
1 parent 9b447f99ff
commit 1c90610617
3 files changed
+268 -111

No files matched your search

@@ -1,5 +1,3 @@
import { useState } from "react"
import { ProductCategory } from "@medusajs/medusa" import { ProductCategory } from "@medusajs/medusa"
import { import {
adminProductCategoryKeys, adminProductCategoryKeys,
@@ -17,23 +15,64 @@ import { NextSelect } from "../../../components/molecules/select/next-select"
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 TreeCrumbs from "../components/tree-crumbs" import TreeCrumbs from "../components/tree-crumbs"
import MetadataForm, {
getSubmittableMetadata,
MetadataFormType,
} from "../../../components/forms/general/metadata-form"
import { Controller, useForm } from "react-hook-form"
import { nestedForm } from "../../../utils/nested-form"
import { TFunction } from "i18next"
import { getDefaultCategoryValues } from "../utils"
const visibilityOptions = (t) => [ export enum CategoryStatus {
Active = "active",
Inactive = "inactive",
}
export enum CategoryVisibility {
Public = "public",
Private = "private",
}
const visibilityOptions = (
t: TFunction<"translation", undefined, "translation">
) => [
{ {
label: t("modals-public", "Public"), label: t("modals-public", "Public"),
value: "public", value: CategoryVisibility.Public,
}, },
{ label: t("modals-private", "Private"), value: "private" }, { label: t("modals-private", "Private"), value: CategoryVisibility.Private },
] ]
const statusOptions = (t) => [ const statusOptions = (
{ label: t("modals-active", "Active"), value: "active" }, t: TFunction<"translation", undefined, "translation">
{ label: t("modals-inactive", "Inactive"), value: "inactive" }, ) => [
{ label: t("modals-active", "Active"), value: CategoryStatus.Active },
{ label: t("modals-inactive", "Inactive"), value: CategoryStatus.Inactive },
] ]
type CreateProductCategoryProps = { type CreateProductCategoryProps = {
closeModal: () => void closeModal: () => void
parentCategory?: ProductCategory parentCategory?: ProductCategory
categories: ProductCategory[]
}
export type CategoryFormData = {
name: string
handle: string | undefined
description: string | undefined
metadata: MetadataFormType
is_active: {
value: CategoryStatus
label: string
}
is_public: {
value: CategoryVisibility
label: string
}
} }
/** /**
@@ -45,23 +84,33 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
const notification = useNotification() const notification = useNotification()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [name, setName] = useState("") const form = useForm<CategoryFormData>({
const [handle, setHandle] = useState("") defaultValues: getDefaultCategoryValues(t),
const [description, setDescription] = useState("") mode: "onChange",
const [isActive, setIsActive] = useState(true) })
const [isPublic, setIsPublic] = useState(true) const {
register,
handleSubmit,
watch,
reset,
control,
formState: { errors, isDirty, isValid, isSubmitting },
} = form
const name = watch("name", "")
const { mutateAsync: createProductCategory } = useAdminCreateProductCategory() const { mutateAsync: createProductCategory } =
useAdminCreateProductCategory()
const onSubmit = async () => { const submit = handleSubmit(async (data) => {
try { try {
await createProductCategory({ await createProductCategory({
name, name: data.name,
handle, handle: data.handle,
description, description: data.description,
is_active: isActive, is_active: data.is_active.value === CategoryStatus.Active,
is_internal: !isPublic, is_internal: data.is_public.value === CategoryVisibility.Private,
parent_category_id: parentCategory?.id ?? null, parent_category_id: parentCategory?.id ?? null,
metadata: getSubmittableMetadata(data.metadata),
}) })
// TODO: temporary here, investigate why `useAdminCreateProductCategory` doesn't invalidate this // TODO: temporary here, investigate why `useAdminCreateProductCategory` doesn't invalidate this
await queryClient.invalidateQueries(adminProductCategoryKeys.lists()) await queryClient.invalidateQueries(adminProductCategoryKeys.lists())
@@ -83,7 +132,7 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
) )
notification(t("modals-error", "Error"), errorMessage, "error") notification(t("modals-error", "Error"), errorMessage, "error")
} }
} })
return ( return (
<FocusModal> <FocusModal>
@@ -96,8 +145,8 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
<Button <Button
size="small" size="small"
variant="primary" variant="primary"
onClick={onSubmit} disabled={!isDirty || !isValid || isSubmitting}
disabled={!name} onClick={submit}
className="rounded-rounded" className="rounded-rounded"
> >
{t("modals-save-category", "Save category")} {t("modals-save-category", "Save category")}
@@ -105,7 +154,6 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
</div> </div>
</div> </div>
</FocusModal.Header> </FocusModal.Header>
<FocusModal.Main className="no-scrollbar flex w-full justify-center"> <FocusModal.Main className="no-scrollbar flex w-full justify-center">
<div className="small:w-4/5 medium:w-7/12 large:w-6/12 my-16 max-w-[700px]"> <div className="small:w-4/5 medium:w-7/12 large:w-6/12 my-16 max-w-[700px]">
<h1 className="inter-xlarge-semibold text-grey-90 pb-6"> <h1 className="inter-xlarge-semibold text-grey-90 pb-6">
@@ -134,61 +182,97 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
<div className="mb-8 flex justify-between gap-6"> <div className="mb-8 flex justify-between gap-6">
<InputField <InputField
required required
label={t("modals-name", "Name")} label={t("modals-name", "Name") as string}
type="string" type="string"
name="name"
value={name}
className="w-[338px]" className="w-[338px]"
placeholder={t( placeholder={
t(
"modals-give-this-category-a-name", "modals-give-this-category-a-name",
"Give this category a name" "Give this category a name"
)} ) as string
onChange={(ev) => setName(ev.target.value)} }
{...register("name", { required: true })}
/> />
<InputField <InputField
label={t("modals-handle", "Handle")} label={t("modals-handle", "Handle") as string}
type="string" type="string"
name="handle"
value={handle}
className="w-[338px]" className="w-[338px]"
placeholder={t("modals-custom-handle", "Custom handle")} placeholder={
onChange={(ev) => setHandle(ev.target.value)} t("modals-custom-handle", "Custom handle") as string
}
{...register("handle")}
/> />
</div> </div>
<div className="mb-8"> <div className="mb-8">
<TextArea <TextArea
label={t("modals-description", "Description")} label={t("modals-description", "Description")}
name="description" placeholder={
value={description} t(
placeholder={t(
"modals-give-this-category-a-description", "modals-give-this-category-a-description",
"Give this category a description" "Give this category a description"
)} ) as string
onChange={(ev) => setDescription(ev.target.value)} }
{...register("description")}
/> />
</div> </div>
<div className="mb-8 flex justify-between gap-6"> <div className="mb-8 flex justify-between gap-6">
<div className="flex-1"> <div className="flex-1">
<Controller
name={"is_active"}
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect <NextSelect
label={t("modals-status", "Status")} {...field}
label={t("modals-status", "Status") as string}
placeholder="Choose status"
options={statusOptions(t)} options={statusOptions(t)}
value={statusOptions(t)[isActive ? 0 : 1]} value={
onChange={(o) => setIsActive(o.value === "active")} statusOptions(t)[
field.value?.value === CategoryStatus.Active ? 0 : 1
]
}
/>
)
}}
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Controller
name={"is_public"}
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect <NextSelect
label={t("modals-visibility", "Visibility")} {...field}
label={
t("modals-visibility", "Visibility") as string
}
placeholder="Choose visibility"
options={visibilityOptions(t)} options={visibilityOptions(t)}
value={visibilityOptions(t)[isPublic ? 0 : 1]} value={
onChange={(o) => setIsPublic(o.value === "public")} visibilityOptions(t)[
field.value.value === CategoryVisibility.Public ? 0 : 1
]
}
/>
)
}}
/> />
</div> </div>
</div> </div>
<div className="mt-xlarge">
<h2 className="inter-base-semibold mb-base">
{t("collection-modal-metadata", "Metadata")}
</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div> </div>
</FocusModal.Main> </FocusModal.Main>
</FocusModal> </FocusModal>
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react" import { useEffect } from "react"
import { ProductCategory } from "@medusajs/medusa" import { ProductCategory } from "@medusajs/medusa"
import { useAdminUpdateProductCategory } from "medusa-react" import { useAdminUpdateProductCategory } from "medusa-react"
@@ -15,26 +15,33 @@ import useNotification from "../../../hooks/use-notification"
import { Option } from "../../../types/shared" import { Option } from "../../../types/shared"
import { getErrorMessage } from "../../../utils/error-messages" import { getErrorMessage } from "../../../utils/error-messages"
import TreeCrumbs from "../components/tree-crumbs" import TreeCrumbs from "../components/tree-crumbs"
import MetadataForm, {
getSubmittableMetadata,
} from "../../../components/forms/general/metadata-form"
import { Controller, useForm } from "react-hook-form"
import { nestedForm } from "../../../utils/nested-form"
import { CategoryFormData, CategoryStatus, CategoryVisibility } from "./add-product-category"
import { getDefaultCategoryValues } from "../utils"
const visibilityOptions: (t: TFunction) => Option[] = (t) => [ const visibilityOptions: (t: TFunction) => Option[] = (t) => [
{ {
label: "Public", label: "Public",
value: "public", value: CategoryVisibility.Public,
}, },
{ label: "Private", value: "private" }, { label: "Private", value: CategoryVisibility.Private },
] ]
const statusOptions: (t: TFunction) => Option[] = (t) => [ const statusOptions: (t: TFunction) => Option[] = (t) => [
{ label: "Active", value: "active" }, { label: "Active", value: CategoryStatus.Active },
{ label: "Inactive", value: "inactive" }, { label: "Inactive", value: CategoryStatus.Inactive },
] ]
type EditProductCategoriesSideModalProps = { type EditProductCategoriesSideModalProps = {
activeCategory: ProductCategory activeCategory: ProductCategory
close: () => void close: () => void
isVisible: boolean isVisible: boolean
categories: ProductCategory[]
} }
/** /**
* Modal for editing product categories * Modal for editing product categories
*/ */
@@ -43,12 +50,6 @@ function EditProductCategoriesSideModal(
) { ) {
const { isVisible, close, activeCategory, categories } = props const { isVisible, close, activeCategory, categories } = props
const [name, setName] = useState("")
const [handle, setHandle] = useState("")
const [description, setDescription] = useState("")
const [isActive, setIsActive] = useState(true)
const [isPublic, setIsPublic] = useState(true)
const { t } = useTranslation() const { t } = useTranslation()
const notification = useNotification() const notification = useNotification()
@@ -56,24 +57,37 @@ function EditProductCategoriesSideModal(
activeCategory?.id activeCategory?.id
) )
const form = useForm<CategoryFormData>({
defaultValues: getDefaultCategoryValues(t, activeCategory),
mode: "onChange",
})
const {
register,
handleSubmit,
watch,
reset,
control,
formState: { errors, isDirty, isValid, isSubmitting },
} = form
useEffect(() => { useEffect(() => {
if (activeCategory) { if (activeCategory) {
setName(activeCategory.name) reset(getDefaultCategoryValues(t, activeCategory))
setHandle(activeCategory.handle)
setDescription(activeCategory.description)
setIsActive(activeCategory.is_active)
setIsPublic(!activeCategory.is_internal)
} }
}, [activeCategory]) }, [activeCategory, reset])
const onSave = async () => { const onSave = async (data: CategoryFormData) => {
try { try {
await updateCategory({ await updateCategory({
name, name: data.name,
handle, handle: data.handle,
description, description: data.description,
is_active: isActive, is_active: data.is_active.value === CategoryStatus.Active,
is_internal: !isPublic, is_internal: data.is_public.value === CategoryVisibility.Private,
metadata: getSubmittableMetadata(data.metadata),
}) })
notification( notification(
@@ -102,21 +116,32 @@ function EditProductCategoriesSideModal(
return ( return (
<SideModal close={onClose} isVisible={!!isVisible}> <SideModal close={onClose} isVisible={!!isVisible}>
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between overflow-auto">
{/* === HEADER === */} {/* === HEADER === */}
<div className="flex items-center justify-between p-6"> <div className="flex items-center justify-between p-6">
<h3 className="inter-large-semibold flex items-center gap-2 text-xl text-gray-900">
{t("modals-edit-product-category", "Edit product category")}
</h3>
<Button <Button
size="small"
variant="secondary" variant="secondary"
className="h-8 w-8 p-2" className="h-8 w-8 p-2"
onClick={props.close} onClick={props.close}
> >
<CrossIcon size={20} className="text-grey-50" /> <CrossIcon size={20} className="text-grey-50" />
</Button> </Button>
<div className="gap-x-small flex">
<Button
size="small"
variant="primary"
disabled={!isDirty || !isValid || isSubmitting}
onClick={handleSubmit(onSave)}
className="rounded-rounded"
>
{t("modals-save-category", "Save category")}
</Button>
</div> </div>
</div>
<h3 className="inter-large-semibold flex items-center gap-2 text-xl text-gray-900 px-6">
{t("modals-edit-product-category", "Edit product category")}
</h3>
{/* === DIVIDER === */} {/* === DIVIDER === */}
<div className="block h-[1px] bg-gray-200" /> <div className="block h-[1px] bg-gray-200" />
@@ -129,68 +154,84 @@ function EditProductCategoriesSideModal(
<div className="flex-grow px-6"> <div className="flex-grow px-6">
<InputField <InputField
required required
label={t("modals-name", "Name")} label={t("modals-name", "Name") as string}
type="string" type="string"
name="name"
value={name}
className="my-6" className="my-6"
placeholder={t( placeholder={
t(
"modals-give-this-category-a-name", "modals-give-this-category-a-name",
"Give this category a name" "Give this category a name"
)} ) as string
onChange={(ev) => setName(ev.target.value)} }
{...register("name", { required: true })}
/> />
<InputField <InputField
required label={t("modals-handle", "Handle") as string}
label={t("modals-handle", "Handle")}
type="string"
name="handle"
value={handle}
className="my-6" className="my-6"
placeholder={t("modals-custom-handle", "Custom handle")} type="string"
onChange={(ev) => setHandle(ev.target.value)} placeholder={
t("modals-custom-handle", "Custom handle") as string
}
{...register("handle")}
/> />
<TextArea <TextArea
label={t("modals-description", "Description")} label={t("modals-description", "Description")}
name="description"
value={description}
className="my-6" className="my-6"
placeholder={t( placeholder={
t(
"modals-give-this-category-a-description", "modals-give-this-category-a-description",
"Give this category a description" "Give this category a description"
)} ) as string
onChange={(ev) => setDescription(ev.target.value)} }
{...register("description")}
/> />
<Controller
name="is_active"
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect <NextSelect
label={t("modals-status", "Status")} {...field}
label={t("modals-status", "Status") as string}
placeholder="Choose status"
options={statusOptions(t)} options={statusOptions(t)}
value={statusOptions(t)[isActive ? 0 : 1]} value={
onChange={(o) => setIsActive(o.value === "active")} statusOptions(t)[field.value?.value === CategoryStatus.Active ? 0 : 1]
}
/>
)
}}
/> />
<Controller
name="is_public"
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect <NextSelect
{...field}
className="my-6" className="my-6"
label={t("modals-visibility", "Visibility")} label={t("modals-visibility", "Visibility") as string}
placeholder="Choose visibility"
options={visibilityOptions(t)} options={visibilityOptions(t)}
value={visibilityOptions(t)[isPublic ? 0 : 1]} value={
onChange={(o) => setIsPublic(o.value === "public")} visibilityOptions(t)[field.value.value === CategoryVisibility.Public ? 0 : 1]
}
/> />
)
}}
/>
<div className="mt-small mb-xlarge">
<h2 className="inter-base-semibold mb-base">
{t("collection-modal-metadata", "Metadata")}
</h2>
<MetadataForm form={nestedForm(form, "metadata")} />
</div> </div>
{/* === DIVIDER === */}
<div className="block h-[1px] bg-gray-200" />
{/* === FOOTER === */}
<div className="flex justify-end gap-2 p-3">
<Button size="small" variant="ghost" onClick={onClose}>
{t("modals-cancel", "Cancel")}
</Button>
<Button size="small" variant="primary" onClick={onSave}>
{t("modals-save-and-close", "Save and close")}
</Button>
</div> </div>
</div> </div>
</SideModal> </SideModal>
@@ -1,3 +1,6 @@
import { ProductCategory } from "@medusajs/medusa"
import { TFunction } from "i18next"
import { CategoryFormData, CategoryStatus, CategoryVisibility } from "../modals/add-product-category"
export const flattenCategoryTree = (rootCategories) => { export const flattenCategoryTree = (rootCategories) => {
return rootCategories.reduce((acc, category) => { return rootCategories.reduce((acc, category) => {
if (category?.category_children.length) { if (category?.category_children.length) {
@@ -29,3 +32,32 @@ export const getAncestors = (targetNode, nodes, acc = []) => {
return acc return acc
} }
export const getDefaultCategoryValues = (t: TFunction, category?: ProductCategory): CategoryFormData => {
return {
name: category?.name || "",
handle: category?.handle || "",
description: category?.description || "",
metadata: {
entries: Object.entries(category?.metadata || {}).map(
([key, value]) => ({
key,
value: value as string,
state: "existing",
})
),
},
is_active: {
value: category?.is_active ? (category?.is_active ? CategoryStatus.Active : CategoryStatus.Inactive) : CategoryStatus.Active,
label: category?.is_active
? t("modals-active", "Public") as string
: t("modals-inactive", "Inactive") as string,
},
is_public: {
value: category?.is_internal ? (category?.is_internal ? CategoryVisibility.Private : CategoryVisibility.Public) : CategoryVisibility.Public,
label: category?.is_internal
? t("modals-private", "Private") as string
: t("modals-public", "Public") as string,
},
}
}