feat(dashboard): manage groups from customer details (#7346)
This commit is contained in:
1 parent
05e42bc6fc
commit
ce75755ac6
11 files changed
+450
-20
No files matched your search
@@ -18,7 +18,9 @@ import { EditCustomerGroupSchema } from "../../v2-routes/customer-groups/custome
|
||||
import { customersQueryKeys } from "./customers"
|
||||
|
||||
const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const
|
||||
const customerGroupsQueryKeys = queryKeysFactory(CUSTOMER_GROUPS_QUERY_KEY)
|
||||
export const customerGroupsQueryKeys = queryKeysFactory(
|
||||
CUSTOMER_GROUPS_QUERY_KEY
|
||||
)
|
||||
|
||||
export const useCustomerGroup = (
|
||||
id: string,
|
||||
@@ -162,7 +164,7 @@ export const useRemoveCustomersFromGroup = (
|
||||
options?: UseMutationOptions<
|
||||
AdminCustomerGroupResponse,
|
||||
Error,
|
||||
{ customer_ids: { id: string }[] }
|
||||
{ customer_ids: string[] }
|
||||
>
|
||||
) => {
|
||||
return useMutation({
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const useCustomerTableFilters = (
|
||||
if (customer_groups && !isGroupsExcluded) {
|
||||
const customerGroupFilter: Filter = {
|
||||
key: "groups",
|
||||
label: t("customers.groups"),
|
||||
label: t("customers.groups.label"),
|
||||
type: "select",
|
||||
multiple: true,
|
||||
options: customer_groups.map((s) => ({
|
||||
|
||||
@@ -414,6 +414,12 @@
|
||||
"hint": "Create a new customer to manage their details.",
|
||||
"successToast": "Customer {{email}} was successfully created."
|
||||
},
|
||||
"groups": {
|
||||
"label": "Customer groups",
|
||||
"remove": "Are you sure you want to remove the customer from \"{{name}}\" customer group?",
|
||||
"removeMany": "Are you sure you want to customer from following customer groups: {{groups}}?",
|
||||
"alreadyAddedTooltip": "The customer is already in this customer group."
|
||||
},
|
||||
"edit": {
|
||||
"header": "Edit Customer",
|
||||
"emailDisabledTooltip": "The email address cannot be changed for registered customers.",
|
||||
@@ -428,7 +434,9 @@
|
||||
"guest": "Guest",
|
||||
"registered": "Registered",
|
||||
"groups": "Groups"
|
||||
}
|
||||
},
|
||||
"registered": "Registered",
|
||||
"guest": "Guest"
|
||||
},
|
||||
"customerGroups": {
|
||||
"domain": "Customer Groups",
|
||||
|
||||
@@ -62,7 +62,7 @@ async function batchAddCustomers(
|
||||
|
||||
async function batchRemoveCustomers(
|
||||
id: string,
|
||||
payload: { customer_ids: { id: string }[] }
|
||||
payload: { customer_ids: string[] }
|
||||
) {
|
||||
return postRequest<AdminCustomerGroupResponse>(
|
||||
`/admin/customer-groups/${id}/customers`,
|
||||
|
||||
@@ -367,6 +367,13 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../v2-routes/customers/customer-edit"),
|
||||
},
|
||||
{
|
||||
path: "add-customer-groups",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../v2-routes/customers/customers-add-customer-group"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+149
-11
@@ -2,21 +2,35 @@ import {
|
||||
AdminCustomerGroupResponse,
|
||||
AdminCustomerResponse,
|
||||
} from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Heading,
|
||||
toast,
|
||||
usePrompt,
|
||||
} from "@medusajs/ui"
|
||||
import { createColumnHelper, RowSelectionState } from "@tanstack/react-table"
|
||||
import { t } from "i18next"
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useCustomerGroups } from "../../../../../hooks/api/customer-groups"
|
||||
import {
|
||||
customerGroupsQueryKeys,
|
||||
useCustomerGroups,
|
||||
useRemoveCustomersFromGroup,
|
||||
} from "../../../../../hooks/api/customer-groups"
|
||||
import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns"
|
||||
import { useCustomerGroupTableFilters } from "../../../../../hooks/table/filters/use-customer-group-table-filters"
|
||||
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { Link } from "react-router-dom"
|
||||
import { client } from "../../../../../lib/client"
|
||||
import { queryClient } from "../../../../../lib/medusa.ts"
|
||||
|
||||
type CustomerGroupSectionProps = {
|
||||
customer: AdminCustomerResponse["customer"]
|
||||
@@ -27,9 +41,13 @@ const PAGE_SIZE = 10
|
||||
export const CustomerGroupSection = ({
|
||||
customer,
|
||||
}: CustomerGroupSectionProps) => {
|
||||
const prompt = usePrompt()
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const { raw, searchParams } = useCustomerGroupTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const { customer_groups, count, isLoading, isError, error } =
|
||||
useCustomerGroups(
|
||||
{
|
||||
@@ -43,7 +61,7 @@ export const CustomerGroupSection = ({
|
||||
)
|
||||
|
||||
const filters = useCustomerGroupTableFilters()
|
||||
const columns = useColumns()
|
||||
const columns = useColumns(customer.id)
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: customer_groups ?? [],
|
||||
@@ -53,10 +71,52 @@ export const CustomerGroupSection = ({
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater: setRowSelection,
|
||||
},
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
const handleRemove = async () => {
|
||||
const customerGroupIds = Object.keys(rowSelection)
|
||||
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("customers.groups.removeMany", {
|
||||
groups: customer_groups
|
||||
.filter((g) => customerGroupIds.includes(g.id))
|
||||
.map((g) => g.name)
|
||||
.join(","),
|
||||
}),
|
||||
confirmText: t("actions.remove"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* TODO: use this for now until add customer groups to customers batch is implemented
|
||||
*/
|
||||
const promises = customerGroupIds.map((id) =>
|
||||
client.customerGroups.removeCustomers(id, {
|
||||
customer_ids: [customer.id],
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: customerGroupsQueryKeys.lists(),
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
@@ -67,6 +127,11 @@ export const CustomerGroupSection = ({
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("customerGroups.domain")}</Heading>
|
||||
<Link to={`/customers/${customer.id}/add-customer-groups`}>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("general.add")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
@@ -79,20 +144,55 @@ export const CustomerGroupSection = ({
|
||||
search
|
||||
pagination
|
||||
orderBy={["name", "created_at", "updated_at"]}
|
||||
commands={[
|
||||
{
|
||||
action: handleRemove,
|
||||
label: t("actions.remove"),
|
||||
shortcut: "r",
|
||||
},
|
||||
]}
|
||||
queryObject={raw}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Add remove association when /customer-groups/:id/batch has been created.
|
||||
const CustomerGroupRowActions = ({
|
||||
group,
|
||||
customerId,
|
||||
}: {
|
||||
group: AdminCustomerGroupResponse["customer_group"]
|
||||
customerId: string
|
||||
}) => {
|
||||
const prompt = usePrompt()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { mutateAsync } = useRemoveCustomersFromGroup(group.id)
|
||||
|
||||
const onRemove = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("customers.groups.remove", {
|
||||
name: group.name,
|
||||
}),
|
||||
confirmText: t("actions.remove"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mutateAsync({ customer_ids: [customerId] })
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
@@ -103,6 +203,11 @@ const CustomerGroupRowActions = ({
|
||||
icon: <PencilSquare />,
|
||||
to: `/customer-groups/${group.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: t("actions.remove"),
|
||||
onClick: onRemove,
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -113,15 +218,48 @@ const CustomerGroupRowActions = ({
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminCustomerGroupResponse["customer_group"]>()
|
||||
|
||||
const useColumns = () => {
|
||||
const useColumns = (customerId: string) => {
|
||||
const columns = useCustomerGroupTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...columns,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <CustomerGroupRowActions group={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<CustomerGroupRowActions
|
||||
group={row.original}
|
||||
customerId={customerId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[columns]
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, Checkbox, Hint, Tooltip, toast } from "@medusajs/ui"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { AdminCustomerGroupResponse } from "@medusajs/types"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import {
|
||||
useCustomerGroups,
|
||||
customerGroupsQueryKeys,
|
||||
} from "../../../../../hooks/api/customer-groups"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns"
|
||||
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
|
||||
import { useCustomerGroupTableFilters } from "../../../../../hooks/table/filters/use-customer-group-table-filters"
|
||||
import { client } from "../../../../../lib/client"
|
||||
import { queryClient } from "../../../../../lib/medusa"
|
||||
|
||||
type AddCustomerGroupsFormProps = {
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export const AddCustomerGroupsSchema = zod.object({
|
||||
customer_group_ids: zod.array(zod.string()).min(1),
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const AddCustomerGroupsForm = ({
|
||||
customerId,
|
||||
}: AddCustomerGroupsFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const form = useForm<zod.infer<typeof AddCustomerGroupsSchema>>({
|
||||
defaultValues: {
|
||||
customer_group_ids: [],
|
||||
},
|
||||
resolver: zodResolver(AddCustomerGroupsSchema),
|
||||
})
|
||||
|
||||
const { setValue } = form
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
"customer_group_ids",
|
||||
Object.keys(rowSelection).filter((k) => rowSelection[k]),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
}
|
||||
)
|
||||
}, [rowSelection, setValue])
|
||||
|
||||
const { searchParams, raw } = useCustomerGroupTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const filters = useCustomerGroupTableFilters()
|
||||
|
||||
const {
|
||||
customer_groups,
|
||||
count,
|
||||
isPending: isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useCustomerGroups({
|
||||
fields: "*customers",
|
||||
...searchParams,
|
||||
})
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||
const state = typeof fn === "function" ? fn(rowSelection) : fn
|
||||
|
||||
const ids = Object.keys(state)
|
||||
|
||||
setValue("customer_group_ids", ids, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
|
||||
setRowSelection(state)
|
||||
}
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: customer_groups ?? [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: (row) => {
|
||||
return !row.original.customers?.map((c) => c.id).includes(customerId)
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
/**
|
||||
* TODO: use this for now until add customer groups to customers batch is implemented
|
||||
*/
|
||||
const promises = data.customer_group_ids.map((id) =>
|
||||
client.customerGroups.addCustomers(id, { customer_ids: [customerId] })
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: customerGroupsQueryKeys.lists(),
|
||||
})
|
||||
|
||||
handleSuccess(`/customers/${customerId}`)
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{form.formState.errors.customer_group_ids && (
|
||||
<Hint variant="error">
|
||||
{form.formState.errors.customer_group_ids.message}
|
||||
</Hint>
|
||||
)}
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isPending}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
filters={filters}
|
||||
orderBy={["name", "created_at", "updated_at"]}
|
||||
isLoading={isLoading}
|
||||
layout="fill"
|
||||
search
|
||||
queryObject={raw}
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminCustomerGroupResponse["customer_group"]>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
const base = useCustomerGroupTableColumns()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isPreSelected = !row.getCanSelect()
|
||||
const isSelected = row.getIsSelected() || isPreSelected
|
||||
|
||||
const Component = (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isPreSelected}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isPreSelected) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("customers.groups.alreadyAddedTooltip")}
|
||||
side="right"
|
||||
>
|
||||
{Component}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return Component
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[t, base]
|
||||
)
|
||||
|
||||
return columns
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./add-customer-groups-form"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { AddCustomerGroupsForm } from "./components/add-customers-form"
|
||||
|
||||
export const CustomerAddCustomerGroups = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<AddCustomerGroupsForm customerId={id!} />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CustomerAddCustomerGroups as Component } from "./customer-add-customer-groups"
|
||||
@@ -11,19 +11,25 @@ const usePrompt = () => {
|
||||
return new Promise((resolve) => {
|
||||
let open = true
|
||||
|
||||
const mountRoot = createRoot(document.createElement("div"))
|
||||
|
||||
const onCancel = () => {
|
||||
open = false
|
||||
render()
|
||||
mountRoot.unmount()
|
||||
resolve(false)
|
||||
|
||||
// TEMP FIX for Radix issue with dropdowns persisting pointer-events: none on body after closing
|
||||
document.body.style.pointerEvents = "auto"
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
open = false
|
||||
resolve(true)
|
||||
render()
|
||||
}
|
||||
mountRoot.unmount()
|
||||
|
||||
const mountRoot = createRoot(document.createElement("div"))
|
||||
// TEMP FIX for Radix issue with dropdowns persisting pointer-events: none on body after closing
|
||||
document.body.style.pointerEvents = "auto"
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
mountRoot.render(
|
||||
|
||||
Reference in new issue
Block a user