From ce75755ac65c65c0125115446655dd3838f49df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Sun, 19 May 2024 11:10:13 +0200 Subject: [PATCH] feat(dashboard): manage groups from customer details (#7346) --- .../src/hooks/api/customer-groups.tsx | 6 +- .../filters/use-customer-table-filters.tsx | 2 +- .../dashboard/src/i18n/translations/en.json | 10 +- .../src/lib/client/customer-groups.ts | 2 +- .../providers/router-provider/route-map.tsx | 7 + .../customer-group-section.tsx | 160 ++++++++++- .../add-customer-groups-form.tsx | 254 ++++++++++++++++++ .../components/add-customers-form/index.ts | 1 + .../customer-add-customer-groups.tsx | 13 + .../customers-add-customer-group/index.ts | 1 + .../ui/src/hooks/use-prompt/use-prompt.tsx | 14 +- 11 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/customer-add-customer-groups.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/index.ts diff --git a/packages/admin-next/dashboard/src/hooks/api/customer-groups.tsx b/packages/admin-next/dashboard/src/hooks/api/customer-groups.tsx index 9d0a188610..a92a084c3e 100644 --- a/packages/admin-next/dashboard/src/hooks/api/customer-groups.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/customer-groups.tsx @@ -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({ diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-customer-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-customer-table-filters.tsx index ca21dc2139..e02fac6afe 100644 --- a/packages/admin-next/dashboard/src/hooks/table/filters/use-customer-table-filters.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-customer-table-filters.tsx @@ -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) => ({ diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 458c3366ad..59a4287d1b 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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", diff --git a/packages/admin-next/dashboard/src/lib/client/customer-groups.ts b/packages/admin-next/dashboard/src/lib/client/customer-groups.ts index f669596ffb..398127e890 100644 --- a/packages/admin-next/dashboard/src/lib/client/customer-groups.ts +++ b/packages/admin-next/dashboard/src/lib/client/customer-groups.ts @@ -62,7 +62,7 @@ async function batchAddCustomers( async function batchRemoveCustomers( id: string, - payload: { customer_ids: { id: string }[] } + payload: { customer_ids: string[] } ) { return postRequest( `/admin/customer-groups/${id}/customers`, diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index 87701f6484..c8572fe7b8 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -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" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx index c8e30d7ba4..b3d812b579 100644 --- a/packages/admin-next/dashboard/src/v2-routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customer-detail/components/customer-group-section/customer-group-section.tsx @@ -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({}) 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 = ({
{t("customerGroups.domain")} + + +
) } -// 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 ( , to: `/customer-groups/${group.id}/edit`, }, + { + label: t("actions.remove"), + onClick: onRemove, + icon: , + }, ], }, ]} @@ -113,15 +218,48 @@ const CustomerGroupRowActions = ({ const columnHelper = createColumnHelper() -const useColumns = () => { +const useColumns = (customerId: string) => { const columns = useCustomerGroupTableColumns() return useMemo( () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), ...columns, columnHelper.display({ id: "actions", - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }), ], [columns] diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx new file mode 100644 index 0000000000..0c8423094e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/add-customer-groups-form.tsx @@ -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>({ + defaultValues: { + customer_group_ids: [], + }, + resolver: zodResolver(AddCustomerGroupsSchema), + }) + + const { setValue } = form + + const [rowSelection, setRowSelection] = useState({}) + + 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 = (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 ( + +
+ +
+ {form.formState.errors.customer_group_ids && ( + + {form.formState.errors.customer_group_ids.message} + + )} + + + + +
+
+ + + +
+
+ ) +} + +const columnHelper = + createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + const base = useCustomerGroupTableColumns() + + const columns = useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isPreSelected = !row.getCanSelect() + const isSelected = row.getIsSelected() || isPreSelected + + const Component = ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + + if (isPreSelected) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + ...base, + ], + [t, base] + ) + + return columns +} diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/index.ts new file mode 100644 index 0000000000..6bf7c05861 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/components/add-customers-form/index.ts @@ -0,0 +1 @@ +export * from "./add-customer-groups-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/customer-add-customer-groups.tsx b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/customer-add-customer-groups.tsx new file mode 100644 index 0000000000..3f4ad3fa9b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/customer-add-customer-groups.tsx @@ -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 ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/index.ts b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/index.ts new file mode 100644 index 0000000000..5cb9e3cab1 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/customers/customers-add-customer-group/index.ts @@ -0,0 +1 @@ +export { CustomerAddCustomerGroups as Component } from "./customer-add-customer-groups" diff --git a/packages/design-system/ui/src/hooks/use-prompt/use-prompt.tsx b/packages/design-system/ui/src/hooks/use-prompt/use-prompt.tsx index 7c2f5bbf67..bc16fbcece 100644 --- a/packages/design-system/ui/src/hooks/use-prompt/use-prompt.tsx +++ b/packages/design-system/ui/src/hooks/use-prompt/use-prompt.tsx @@ -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(