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:
LULU
2023-12-07 10:51:21 +01:00
committed by GitHub
co-authored by lulu Philip Korsholm Kasper Fabricius Kristensen Oli Juhl
parent 9b447f99ff
commit 1c90610617
3 changed files with 287 additions and 130 deletions
@@ -1,5 +1,3 @@
import { useState } from "react"
import { ProductCategory } from "@medusajs/medusa"
import {
adminProductCategoryKeys,
@@ -17,23 +15,64 @@ import { NextSelect } from "../../../components/molecules/select/next-select"
import useNotification from "../../../hooks/use-notification"
import { getErrorMessage } from "../../../utils/error-messages"
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"),
value: "public",
value: CategoryVisibility.Public,
},
{ label: t("modals-private", "Private"), value: "private" },
{ label: t("modals-private", "Private"), value: CategoryVisibility.Private },
]
const statusOptions = (t) => [
{ label: t("modals-active", "Active"), value: "active" },
{ label: t("modals-inactive", "Inactive"), value: "inactive" },
const statusOptions = (
t: TFunction<"translation", undefined, "translation">
) => [
{ label: t("modals-active", "Active"), value: CategoryStatus.Active },
{ label: t("modals-inactive", "Inactive"), value: CategoryStatus.Inactive },
]
type CreateProductCategoryProps = {
closeModal: () => void
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 queryClient = useQueryClient()
const [name, setName] = useState("")
const [handle, setHandle] = useState("")
const [description, setDescription] = useState("")
const [isActive, setIsActive] = useState(true)
const [isPublic, setIsPublic] = useState(true)
const form = useForm<CategoryFormData>({
defaultValues: getDefaultCategoryValues(t),
mode: "onChange",
})
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 {
await createProductCategory({
name,
handle,
description,
is_active: isActive,
is_internal: !isPublic,
name: data.name,
handle: data.handle,
description: data.description,
is_active: data.is_active.value === CategoryStatus.Active,
is_internal: data.is_public.value === CategoryVisibility.Private,
parent_category_id: parentCategory?.id ?? null,
metadata: getSubmittableMetadata(data.metadata),
})
// TODO: temporary here, investigate why `useAdminCreateProductCategory` doesn't invalidate this
await queryClient.invalidateQueries(adminProductCategoryKeys.lists())
@@ -83,7 +132,7 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
)
notification(t("modals-error", "Error"), errorMessage, "error")
}
}
})
return (
<FocusModal>
@@ -96,8 +145,8 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
<Button
size="small"
variant="primary"
onClick={onSubmit}
disabled={!name}
disabled={!isDirty || !isValid || isSubmitting}
onClick={submit}
className="rounded-rounded"
>
{t("modals-save-category", "Save category")}
@@ -105,7 +154,6 @@ function CreateProductCategory(props: CreateProductCategoryProps) {
</div>
</div>
</FocusModal.Header>
<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]">
<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">
<InputField
required
label={t("modals-name", "Name")}
label={t("modals-name", "Name") as string}
type="string"
name="name"
value={name}
className="w-[338px]"
placeholder={t(
"modals-give-this-category-a-name",
"Give this category a name"
)}
onChange={(ev) => setName(ev.target.value)}
placeholder={
t(
"modals-give-this-category-a-name",
"Give this category a name"
) as string
}
{...register("name", { required: true })}
/>
<InputField
label={t("modals-handle", "Handle")}
label={t("modals-handle", "Handle") as string}
type="string"
name="handle"
value={handle}
className="w-[338px]"
placeholder={t("modals-custom-handle", "Custom handle")}
onChange={(ev) => setHandle(ev.target.value)}
placeholder={
t("modals-custom-handle", "Custom handle") as string
}
{...register("handle")}
/>
</div>
<div className="mb-8">
<TextArea
label={t("modals-description", "Description")}
name="description"
value={description}
placeholder={t(
"modals-give-this-category-a-description",
"Give this category a description"
)}
onChange={(ev) => setDescription(ev.target.value)}
placeholder={
t(
"modals-give-this-category-a-description",
"Give this category a description"
) as string
}
{...register("description")}
/>
</div>
<div className="mb-8 flex justify-between gap-6">
<div className="flex-1">
<NextSelect
label={t("modals-status", "Status")}
options={statusOptions(t)}
value={statusOptions(t)[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")}
<Controller
name={"is_active"}
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect
{...field}
label={t("modals-status", "Status") as string}
placeholder="Choose status"
options={statusOptions(t)}
value={
statusOptions(t)[
field.value?.value === CategoryStatus.Active ? 0 : 1
]
}
/>
)
}}
/>
</div>
<div className="flex-1">
<NextSelect
label={t("modals-visibility", "Visibility")}
options={visibilityOptions(t)}
value={visibilityOptions(t)[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")}
<Controller
name={"is_public"}
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect
{...field}
label={
t("modals-visibility", "Visibility") as string
}
placeholder="Choose visibility"
options={visibilityOptions(t)}
value={
visibilityOptions(t)[
field.value.value === CategoryVisibility.Public ? 0 : 1
]
}
/>
)
}}
/>
</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>
</FocusModal.Main>
</FocusModal>
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useEffect } from "react"
import { ProductCategory } from "@medusajs/medusa"
import { useAdminUpdateProductCategory } from "medusa-react"
@@ -15,26 +15,33 @@ import useNotification from "../../../hooks/use-notification"
import { Option } from "../../../types/shared"
import { getErrorMessage } from "../../../utils/error-messages"
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) => [
{
label: "Public",
value: "public",
value: CategoryVisibility.Public,
},
{ label: "Private", value: "private" },
{ label: "Private", value: CategoryVisibility.Private },
]
const statusOptions: (t: TFunction) => Option[] = (t) => [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
{ label: "Active", value: CategoryStatus.Active },
{ label: "Inactive", value: CategoryStatus.Inactive },
]
type EditProductCategoriesSideModalProps = {
activeCategory: ProductCategory
close: () => void
isVisible: boolean
categories: ProductCategory[]
}
/**
* Modal for editing product categories
*/
@@ -43,12 +50,6 @@ function EditProductCategoriesSideModal(
) {
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 notification = useNotification()
@@ -56,24 +57,37 @@ function EditProductCategoriesSideModal(
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(() => {
if (activeCategory) {
setName(activeCategory.name)
setHandle(activeCategory.handle)
setDescription(activeCategory.description)
setIsActive(activeCategory.is_active)
setIsPublic(!activeCategory.is_internal)
reset(getDefaultCategoryValues(t, activeCategory))
}
}, [activeCategory])
}, [activeCategory, reset])
const onSave = async () => {
const onSave = async (data: CategoryFormData) => {
try {
await updateCategory({
name,
handle,
description,
is_active: isActive,
is_internal: !isPublic,
name: data.name,
handle: data.handle,
description: data.description,
is_active: data.is_active.value === CategoryStatus.Active,
is_internal: data.is_public.value === CategoryVisibility.Private,
metadata: getSubmittableMetadata(data.metadata),
})
notification(
@@ -102,21 +116,32 @@ function EditProductCategoriesSideModal(
return (
<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 === */}
<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
size="small"
variant="secondary"
className="h-8 w-8 p-2"
onClick={props.close}
>
<CrossIcon size={20} className="text-grey-50" />
</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>
<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 === */}
<div className="block h-[1px] bg-gray-200" />
@@ -129,72 +154,88 @@ function EditProductCategoriesSideModal(
<div className="flex-grow px-6">
<InputField
required
label={t("modals-name", "Name")}
label={t("modals-name", "Name") as string}
type="string"
name="name"
value={name}
className="my-6"
placeholder={t(
"modals-give-this-category-a-name",
"Give this category a name"
)}
onChange={(ev) => setName(ev.target.value)}
placeholder={
t(
"modals-give-this-category-a-name",
"Give this category a name"
) as string
}
{...register("name", { required: true })}
/>
<InputField
required
label={t("modals-handle", "Handle")}
type="string"
name="handle"
value={handle}
label={t("modals-handle", "Handle") as string}
className="my-6"
placeholder={t("modals-custom-handle", "Custom handle")}
onChange={(ev) => setHandle(ev.target.value)}
type="string"
placeholder={
t("modals-custom-handle", "Custom handle") as string
}
{...register("handle")}
/>
<TextArea
label={t("modals-description", "Description")}
name="description"
value={description}
className="my-6"
placeholder={t(
"modals-give-this-category-a-description",
"Give this category a description"
)}
onChange={(ev) => setDescription(ev.target.value)}
placeholder={
t(
"modals-give-this-category-a-description",
"Give this category a description"
) as string
}
{...register("description")}
/>
<NextSelect
label={t("modals-status", "Status")}
options={statusOptions(t)}
value={statusOptions(t)[isActive ? 0 : 1]}
onChange={(o) => setIsActive(o.value === "active")}
<Controller
name="is_active"
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect
{...field}
label={t("modals-status", "Status") as string}
placeholder="Choose status"
options={statusOptions(t)}
value={
statusOptions(t)[field.value?.value === CategoryStatus.Active ? 0 : 1]
}
/>
)
}}
/>
<NextSelect
className="my-6"
label={t("modals-visibility", "Visibility")}
options={visibilityOptions(t)}
value={visibilityOptions(t)[isPublic ? 0 : 1]}
onChange={(o) => setIsPublic(o.value === "public")}
<Controller
name="is_public"
control={control}
rules={{ required: true }}
render={({ field }) => {
return (
<NextSelect
{...field}
className="my-6"
label={t("modals-visibility", "Visibility") as string}
placeholder="Choose visibility"
options={visibilityOptions(t)}
value={
visibilityOptions(t)[field.value.value === CategoryVisibility.Public ? 0 : 1]
}
/>
)
}}
/>
</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 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>
</div>
</SideModal>
)
}
export default EditProductCategoriesSideModal
export default EditProductCategoriesSideModal
@@ -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) => {
return rootCategories.reduce((acc, category) => {
if (category?.category_children.length) {
@@ -29,3 +32,32 @@ export const getAncestors = (targetNode, nodes, 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,
},
}
}