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:
co-authored by
lulu
Philip Korsholm
Kasper Fabricius Kristensen
Oli Juhl
parent
9b447f99ff
commit
1c90610617
+138
-54
@@ -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>
|
||||
|
||||
+117
-76
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user