feat(dashboard,medusa,types): Add Product Tag management (#8349)

Resolves CC-69
This commit is contained in:
Kasper Fabricius Kristensen
2024-07-31 09:21:09 +02:00
committed by GitHub
parent 4fda46d9b1
commit 6629be92e1
32 changed files with 895 additions and 74 deletions

View File

@@ -62,6 +62,13 @@ const PRODUCT_TYPE_INJECTION_ZONES = [
"product_type.list.after",
] as const
const PRODUCT_TAG_INJECTION_ZONES = [
"product_tag.details.before",
"product_tag.details.after",
"product_tag.list.before",
"product_tag.list.after",
] as const
const PRICE_LIST_INJECTION_ZONES = [
"price_list.details.before",
"price_list.details.after",
@@ -206,4 +213,5 @@ export const INJECTION_ZONES = [
...CAMPAIGN_INJECTION_ZONES,
...TAX_INJECTION_ZONES,
...PRODUCT_TYPE_INJECTION_ZONES,
...PRODUCT_TAG_INJECTION_ZONES,
] as const

View File

@@ -50,6 +50,10 @@ const useSettingRoutes = (): NavItemProps[] => {
label: t("productTypes.domain"),
to: "/settings/product-types",
},
{
label: t("productTags.domain"),
to: "/settings/product-tags",
},
{
label: t("stockLocations.domain"),
to: "/settings/locations",

View File

@@ -1,14 +1,22 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import {
QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const TAGS_QUERY_KEY = "tags" as const
export const tagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
export const useTag = (
export const useProductTag = (
id: string,
query?: HttpTypes.AdminProductTagParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminProductTagResponse,
@@ -20,7 +28,7 @@ export const useTag = (
>
) => {
const { data, ...rest } = useQuery({
queryKey: tagsQueryKeys.detail(id),
queryKey: productTagsQueryKeys.detail(id, query),
queryFn: async () => sdk.admin.productTag.retrieve(id),
...options,
})
@@ -28,7 +36,7 @@ export const useTag = (
return { ...data, ...rest }
}
export const useTags = (
export const useProductTags = (
query?: HttpTypes.AdminProductTagListParams,
options?: Omit<
UseQueryOptions<
@@ -41,10 +49,80 @@ export const useTags = (
>
) => {
const { data, ...rest } = useQuery({
queryKey: tagsQueryKeys.list(query),
queryKey: productTagsQueryKeys.list(query),
queryFn: async () => sdk.admin.productTag.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCreateProductTag = (
query?: HttpTypes.AdminProductTagParams,
options?: UseMutationOptions<
HttpTypes.AdminProductTagResponse,
FetchError,
HttpTypes.AdminCreateProductTag
>
) => {
return useMutation({
mutationFn: async (data) => sdk.admin.productTag.create(data, query),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: productTagsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateProductTag = (
id: string,
query?: HttpTypes.AdminProductTagParams,
options?: UseMutationOptions<
HttpTypes.AdminProductTagResponse,
FetchError,
HttpTypes.AdminUpdateProductTag
>
) => {
return useMutation({
mutationFn: async (data) => sdk.admin.productTag.update(id, data, query),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: productTagsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: productTagsQueryKeys.detail(data.product_tag.id, query),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteProductTag = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminProductTagDeleteResponse,
FetchError,
void
>
) => {
return useMutation({
mutationFn: async () => sdk.admin.productTag.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: productTagsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: productTagsQueryKeys.detail(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
import { useProductTags } from "../../api"
import { useProductTypes } from "../../api/product-types"
import { useSalesChannels } from "../../api/sales-channels"
@@ -8,6 +9,7 @@ const excludeableFields = [
"collections",
"categories",
"product_types",
"product_tags",
] as const
export const useProductTableFilters = (
@@ -27,6 +29,13 @@ export const useProductTableFilters = (
}
)
const isProductTagExcluded = exclude?.includes("product_tags")
const { product_tags } = useProductTags({
limit: 1000,
offset: 0,
})
// const { product_tags } = useAdminProductTags({
// limit: 1000,
// offset: 0,
@@ -84,6 +93,21 @@ export const useProductTableFilters = (
filters = [...filters, typeFilter]
}
if (product_tags && !isProductTagExcluded) {
const tagFilter: Filter = {
key: "tags",
label: t("fields.tag"),
type: "select",
multiple: true,
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
})),
}
filters = [...filters, tagFilter]
}
// if (product_tags) {
// const tagFilter: Filter = {
// key: "tags",
@@ -144,21 +168,21 @@ export const useProductTableFilters = (
// filters = [...filters, collectionFilter]
// }
const giftCardFilter: Filter = {
key: "is_giftcard",
label: t("fields.giftCard"),
type: "select",
options: [
{
label: t("fields.true"),
value: "true",
},
{
label: t("fields.false"),
value: "false",
},
],
}
// const giftCardFilter: Filter = {
// key: "is_giftcard",
// label: t("fields.giftCard"),
// type: "select",
// options: [
// {
// label: t("fields.true"),
// value: "true",
// },
// {
// label: t("fields.false"),
// value: "false",
// },
// ],
// }
const statusFilter: Filter = {
key: "status",
@@ -194,7 +218,7 @@ export const useProductTableFilters = (
type: "date",
}))
filters = [...filters, statusFilter, giftCardFilter, ...dateFilters]
filters = [...filters, statusFilter, ...dateFilters]
return filters
}

View File

@@ -2172,6 +2172,26 @@
"value": "Value"
}
},
"productTags": {
"domain": "Product Tags",
"create": {
"header": "Create Product Tag",
"subtitle": "Create a new product tag to categorize your products.",
"successToast": "Product tag {{value}} was successfully created."
},
"edit": {
"header": "Edit Product Tag",
"subtitle": "Edit the value of the product tag.",
"successToast": "Product tag {{value}} was successfully updated."
},
"delete": {
"confirmation": "You are about to delete the product tag {{value}}. This action cannot be undone.",
"successToast": "Product tag {{value}} was successfully deleted."
},
"fields": {
"value": "Value"
}
},
"notifications": {
"domain": "Notifications",
"emptyState": {

View File

@@ -1,9 +1,7 @@
import {
AdminApiKeyResponse,
AdminProductCategoryResponse,
AdminTaxRegionResponse,
HttpTypes,
SalesChannelDTO,
} from "@medusajs/types"
import { Outlet, RouteObject } from "react-router-dom"
@@ -739,7 +737,7 @@ export const RouteMap: RouteObject[] = [
path: ":id",
lazy: () => import("../../routes/users/user-detail"),
handle: {
crumb: (data: { user: UserDTO }) => data.user.email,
crumb: (data: HttpTypes.AdminUserResponse) => data.user.email,
},
children: [
{
@@ -776,7 +774,7 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/sales-channels/sales-channel-detail"),
handle: {
crumb: (data: { sales_channel: SalesChannelDTO }) =>
crumb: (data: HttpTypes.AdminSalesChannelResponse) =>
data.sales_channel.name,
},
children: [
@@ -944,7 +942,43 @@ export const RouteMap: RouteObject[] = [
},
],
},
{
path: "product-tags",
element: <Outlet />,
handle: {
crumb: () => "Product Tags",
},
children: [
{
path: "",
lazy: () =>
import("../../routes/product-tags/product-tag-list"),
children: [
{
path: "create",
lazy: () =>
import("../../routes/product-tags/product-tag-create"),
},
],
},
{
path: ":id",
lazy: () =>
import("../../routes/product-tags/product-tag-detail"),
handle: {
crumb: (data: HttpTypes.AdminProductTagResponse) =>
data.product_tag.value,
},
children: [
{
path: "edit",
lazy: () =>
import("../../routes/product-tags/product-tag-edit"),
},
],
},
],
},
{
path: "workflows",
element: <Outlet />,
@@ -1051,7 +1085,7 @@ export const RouteMap: RouteObject[] = [
"../../routes/api-key-management/api-key-management-detail"
),
handle: {
crumb: (data: AdminApiKeyResponse) => {
crumb: (data: HttpTypes.AdminApiKeyResponse) => {
return data.api_key.title
},
},
@@ -1110,7 +1144,7 @@ export const RouteMap: RouteObject[] = [
"../../routes/api-key-management/api-key-management-detail"
),
handle: {
crumb: (data: AdminApiKeyResponse) => {
crumb: (data: HttpTypes.AdminApiKeyResponse) => {
return data.api_key.title
},
},

View File

@@ -0,0 +1,52 @@
import { HttpTypes } from "@medusajs/types"
import { toast, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useDeleteProductTag } from "../../../../hooks/api"
type UseDeleteProductTagActionProps = {
productTag: HttpTypes.AdminProductTag
}
export const useDeleteProductTagAction = ({
productTag,
}: UseDeleteProductTagActionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const { mutateAsync } = useDeleteProductTag(productTag.id)
const handleDelete = async () => {
const confirmed = await prompt({
title: t("general.areYouSure"),
description: t("productTags.delete.confirmation", {
value: productTag.value,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!confirmed) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
toast.success(
t("productTags.delete.successToast", {
value: productTag.value,
})
)
navigate("/settings/product-tags", {
replace: true,
})
},
onError: (error) => {
toast.error(error.message)
},
})
}
return handleDelete
}

View File

@@ -0,0 +1 @@
export * from "./product-tag-create-form"

View File

@@ -0,0 +1,99 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { useCreateProductTag } from "../../../../../hooks/api"
const ProductTagCreateSchema = z.object({
value: z.string().min(1),
})
export const ProductTagCreateForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof ProductTagCreateSchema>>({
defaultValues: {
value: "",
},
resolver: zodResolver(ProductTagCreateSchema),
})
const { mutateAsync, isPending } = useCreateProductTag()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ product_tag }) => {
toast.success(
t("productTags.create.successToast", {
value: product_tag.value,
})
)
handleSuccess(`../${product_tag.id}`)
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteFocusModal.Form form={form}>
<form
className="flex size-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex flex-1 justify-center overflow-auto px-6 py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div className="flex flex-col gap-y-1">
<RouteFocusModal.Title asChild>
<Heading>{t("productTags.create.header")}</Heading>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<Text size="small" className="text-ui-fg-subtle">
{t("productTags.create.subtitle")}
</Text>
</RouteFocusModal.Description>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("productTags.fields.value")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary" type="button">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1 @@
export { ProductTagCreate as Component } from "./product-tag-create"

View File

@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/modals"
import { ProductTagCreateForm } from "./components/product-tag-create-form"
export const ProductTagCreate = () => {
return (
<RouteFocusModal>
<ProductTagCreateForm />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1 @@
export * from "./product-tag-general-section"

View File

@@ -0,0 +1,48 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
type ProductTagGeneralSectionProps = {
productTag: HttpTypes.AdminProductTag
}
export const ProductTagGeneralSection = ({
productTag,
}: ProductTagGeneralSectionProps) => {
const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag })
return (
<Container className="flex items-center justify-between">
<div className="flex items-center gap-x-1.5">
<span className="text-ui-fg-muted h1-core">#</span>
<Heading>{productTag.value}</Heading>
</div>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: "edit",
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./product-tag-product-section"

View File

@@ -0,0 +1,69 @@
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { DataTable } from "../../../../../components/table/data-table"
import { useProducts } from "../../../../../hooks/api"
import { useProductTableColumns } from "../../../../../hooks/table/columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters"
import { useProductTableQuery } from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table"
type ProductTagProductSectionProps = {
productTag: HttpTypes.AdminProductTag
}
const PAGE_SIZE = 10
const PREFIX = "pt"
export const ProductTagProductSection = ({
productTag,
}: ProductTagProductSectionProps) => {
const { t } = useTranslation()
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { products, count, isPending, isError, error } = useProducts({
...searchParams,
tags: productTag.id,
})
const filters = useProductTableFilters(["product_tags"])
const columns = useProductTableColumns()
const { table } = useDataTable({
data: products,
count,
columns,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
if (isError) {
throw error
}
return (
<Container className="divide-y px-0 py-0">
<div className="px-6 py-4">
<Heading level="h2">{t("products.domain")}</Heading>
</div>
<DataTable
table={table}
filters={filters}
queryObject={raw}
isLoading={isPending}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
navigateTo={(row) => row.original.id}
search
pagination
orderBy={["title", "status", "created_at", "updated_at"]}
/>
</Container>
)
}

View File

@@ -0,0 +1,2 @@
export { productTagLoader as loader } from "./loader"
export { ProductTagDetail as Component } from "./product-tag-detail"

View File

@@ -0,0 +1,20 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { productTagsQueryKeys } from "../../../hooks/api"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
const productTagDetailQuery = (id: string) => ({
queryKey: productTagsQueryKeys.detail(id),
queryFn: async () => sdk.admin.productTag.retrieve(id),
})
export const productTagLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = productTagDetailQuery(id!)
return (
queryClient.getQueryData<any>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,42 @@
import { useLoaderData, useParams } from "react-router-dom"
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
import { SingleColumnPage } from "../../../components/layout/pages"
import { useProductTag } from "../../../hooks/api"
import { ProductTagGeneralSection } from "./components/product-tag-general-section"
import { ProductTagProductSection } from "./components/product-tag-product-section"
import { productTagLoader } from "./loader"
import after from "virtual:medusa/widgets/product_tag/details/after"
import before from "virtual:medusa/widgets/product_tag/details/before"
export const ProductTagDetail = () => {
const { id } = useParams()
const initialData = useLoaderData() as Awaited<
ReturnType<typeof productTagLoader>
>
const { product_tag, isPending, isError, error } = useProductTag(
id!,
undefined,
{
initialData,
}
)
if (isPending || !product_tag) {
return <SingleColumnPageSkeleton showJSON sections={2} />
}
if (isError) {
throw error
}
return (
<SingleColumnPage widgets={{ after, before }} showJSON data={product_tag}>
<ProductTagGeneralSection productTag={product_tag} />
<ProductTagProductSection productTag={product_tag} />
</SingleColumnPage>
)
}

View File

@@ -0,0 +1 @@
export * from "./product-tag-edit-form"

View File

@@ -0,0 +1,87 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Input, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { useUpdateProductTag } from "../../../../../hooks/api"
type ProductTagEditFormProps = {
productTag: HttpTypes.AdminProductTag
}
const ProductTagEditSchema = z.object({
value: z.string().min(1),
})
export const ProductTagEditForm = ({ productTag }: ProductTagEditFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof ProductTagEditSchema>>({
defaultValues: {
value: productTag.value,
},
resolver: zodResolver(ProductTagEditSchema),
})
const { mutateAsync, isPending } = useUpdateProductTag(productTag.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ product_tag }) => {
toast.success(
t("productTags.edit.successToast", {
value: product_tag.value,
})
)
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteDrawer.Form form={form}>
<form
className="flex size-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteDrawer.Body className="flex flex-1 flex-col overflow-auto">
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("productTags.fields.value")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small" type="button">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export { ProductTagEdit as Component } from "./product-tag-edit"

View File

@@ -0,0 +1,33 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals"
import { useProductTag } from "../../../hooks/api"
import { ProductTagEditForm } from "./components/product-tag-edit-form"
export const ProductTagEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product_tag, isPending, isError, error } = useProductTag(id!)
const ready = !isPending && !!product_tag
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("productTags.edit.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("productTags.edit.subtitle")}
</RouteDrawer.Description>
</RouteDrawer.Header>
{ready && <ProductTagEditForm productTag={product_tag} />}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export * from "./product-tag-list-table"

View File

@@ -0,0 +1,129 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, useLoaderData } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useProductTags } from "../../../../../hooks/api"
import { useProductTagTableColumns } from "../../../../../hooks/table/columns"
import { useProductTagTableFilters } from "../../../../../hooks/table/filters"
import { useProductTagTableQuery } from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
import { productTagListLoader } from "../../loader"
const PAGE_SIZE = 20
export const ProductTagListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useProductTagTableQuery({
pageSize: PAGE_SIZE,
})
const initialData = useLoaderData() as Awaited<
ReturnType<typeof productTagListLoader>
>
const { product_tags, count, isPending, isError, error } = useProductTags(
searchParams,
{
initialData,
placeholderData: keepPreviousData,
}
)
const columns = useColumns()
const filters = useProductTagTableFilters()
const { table } = useDataTable({
data: product_tags,
count,
columns,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y px-0 py-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("productTags.domain")}</Heading>
<Button variant="secondary" size="small" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
filters={filters}
queryObject={raw}
isLoading={isPending}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
navigateTo={(row) => row.original.id}
search
pagination
orderBy={["value", "created_at", "updated_at"]}
/>
</Container>
)
}
const ProductTagRowActions = ({
productTag,
}: {
productTag: HttpTypes.AdminProductTag
}) => {
const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag })
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `${productTag.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<HttpTypes.AdminProductTag>()
const useColumns = () => {
const base = useProductTagTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => <ProductTagRowActions productTag={row.original} />,
}),
],
[base]
)
}

View File

@@ -0,0 +1,2 @@
export { productTagListLoader as loader } from "./loader"
export { ProductTagList as Component } from "./product-tag-list"

View File

@@ -0,0 +1,34 @@
import { HttpTypes } from "@medusajs/types"
import { LoaderFunctionArgs } from "react-router-dom"
import { productTagsQueryKeys } from "../../../hooks/api"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
const productTagListQuery = (query?: HttpTypes.AdminProductTagListParams) => ({
queryKey: productTagsQueryKeys.list(query),
queryFn: async () => sdk.admin.productTag.list(query),
})
export const productTagListLoader = async ({ request }: LoaderFunctionArgs) => {
const searchParams = new URL(request.url).searchParams
const queryObject: Record<string, string> = {}
searchParams.forEach((value, key) => {
try {
queryObject[key] = JSON.parse(value)
} catch (_e) {
queryObject[key] = value
}
})
const query = productTagListQuery(
queryObject as HttpTypes.AdminProductTagListParams
)
return (
queryClient.getQueryData<any>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,21 @@
import { SingleColumnPage } from "../../../components/layout/pages"
import { ProductTagListTable } from "./components/product-tag-list-table"
import after from "virtual:medusa/widgets/product_tag/list/after"
import before from "virtual:medusa/widgets/product_tag/list/before"
export const ProductTagList = () => {
return (
<SingleColumnPage
showMetadata={false}
showJSON={false}
hasOutlet
widgets={{
after,
before,
}}
>
<ProductTagListTable />
</SingleColumnPage>
)
}

View File

@@ -2,46 +2,46 @@ import { HttpTypes } from "@medusajs/types"
import { Button, Checkbox } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import {
OnChangeFn,
RowSelectionState,
createColumnHelper,
OnChangeFn,
RowSelectionState,
createColumnHelper,
} from "@tanstack/react-table"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import {
StackedDrawer,
StackedFocusModal,
StackedDrawer,
StackedFocusModal,
} from "../../../../../components/modals"
import { DataTable } from "../../../../../components/table/data-table"
import {
useCollections,
useCustomerGroups,
useProductTypes,
useProducts,
useTags,
useCollections,
useCustomerGroups,
useProductTags,
useProductTypes,
useProducts,
} from "../../../../../hooks/api"
import {
useCollectionTableColumns,
useCustomerGroupTableColumns,
useProductTableColumns,
useProductTagTableColumns,
useProductTypeTableColumns,
useCollectionTableColumns,
useCustomerGroupTableColumns,
useProductTableColumns,
useProductTagTableColumns,
useProductTypeTableColumns,
} from "../../../../../hooks/table/columns"
import {
useCollectionTableFilters,
useCustomerGroupTableFilters,
useProductTableFilters,
useProductTagTableFilters,
useProductTypeTableFilters,
useCollectionTableFilters,
useCustomerGroupTableFilters,
useProductTableFilters,
useProductTagTableFilters,
useProductTypeTableFilters,
} from "../../../../../hooks/table/filters"
import {
useCollectionTableQuery,
useCustomerGroupTableQuery,
useProductTableQuery,
useProductTagTableQuery,
useProductTypeTableQuery,
useCollectionTableQuery,
useCustomerGroupTableQuery,
useProductTableQuery,
useProductTagTableQuery,
useProductTypeTableQuery,
} from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { TaxRateRuleReferenceType } from "../../constants"
@@ -664,7 +664,7 @@ const ProductTagTable = ({
prefix: PREFIX_PRODUCT_TAG,
})
const { product_tags, count, isLoading, isError, error } = useTags(
const { product_tags, count, isLoading, isError, error } = useProductTags(
searchParams,
{
placeholderData: keepPreviousData,

View File

@@ -1,8 +1,8 @@
import {
ArrowDownRightMini,
PencilSquare,
Trash,
TriangleRightMini,
ArrowDownRightMini,
PencilSquare,
Trash,
TriangleRightMini,
} from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Badge, IconButton, StatusBadge, Text, Tooltip } from "@medusajs/ui"
@@ -17,7 +17,7 @@ import { useCollections } from "../../../../../hooks/api/collections"
import { useCustomerGroups } from "../../../../../hooks/api/customer-groups"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useProducts } from "../../../../../hooks/api/products"
import { useTags } from "../../../../../hooks/api/tags"
import { useProductTags } from "../../../../../hooks/api/tags"
import { formatPercentage } from "../../../../../lib/percentage-helpers"
import { TaxRateRuleReferenceType } from "../../constants"
import { useDeleteTaxRateAction } from "../../hooks"
@@ -277,7 +277,7 @@ const useReferenceValues = (
}
)
const tags = useTags(
const tags = useProductTags(
{
id: ids,
limit: 10,

View File

@@ -8,7 +8,7 @@ import { useCollections } from "../../../hooks/api/collections"
import { useCustomerGroups } from "../../../hooks/api/customer-groups"
import { useProductTypes } from "../../../hooks/api/product-types"
import { useProducts } from "../../../hooks/api/products"
import { useTags } from "../../../hooks/api/tags"
import { useProductTags } from "../../../hooks/api/tags"
import { useTaxRate } from "../../../hooks/api/tax-rates"
import { TaxRateRuleReferenceType } from "../common/constants"
import { TaxRegionTaxOverrideEditForm } from "./components/tax-region-tax-override-edit-form"
@@ -93,7 +93,7 @@ const useDefaultRulesValues = (
},
{
ids: idsByReferenceType[TaxRateRuleReferenceType.PRODUCT_TAG],
hook: useTags,
hook: useProductTags,
key: TaxRateRuleReferenceType.PRODUCT_TAG,
getResult: (result: any) =>
result.tags.map((tag: any) => ({

View File

@@ -111,13 +111,11 @@ export interface BaseProductListParams
handle?: string | string[]
id?: string | string[]
is_giftcard?: boolean
tags?: {
value?: string[]
}
tags?: string | string[]
type_id?: string | string[]
category_id?: string | string[] | OperatorMap<string>
categories?: { id: OperatorMap<string> } | { id: OperatorMap<string[]> }
collection_id?: string | string[] | OperatorMap<string>
category_id?: string | string[]
categories?: string | string[]
collection_id?: string | string[]
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>

View File

@@ -11,11 +11,11 @@ export const GetProductsParams = z.object({
title: z.string().nullish(),
handle: z.string().nullish(),
is_giftcard: OptionalBooleanValidator,
category_id: z.string().array().nullish(),
sales_channel_id: z.string().array().nullish(),
collection_id: z.string().array().nullish(),
tags: z.string().array().optional(),
type_id: z.string().array().optional(),
category_id: z.union([z.string(), z.array(z.string())]).nullish(),
sales_channel_id: z.union([z.string(), z.array(z.string())]).nullish(),
collection_id: z.union([z.string(), z.array(z.string())]).nullish(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
type_id: z.union([z.string(), z.array(z.string())]).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),