feat(dashboard,medusa,ui): Manual gift cards + cleanup (#6380)

**Dashboard**
- Adds different views for managing manual/custom gift cards (not associated with a product gift card).
- Cleans up several table implementations to use new DataTable component.
- Minor cleanup of translation file.

**Medusa**
- Adds missing query params for list endpoints in the following admin domains: /customers, /customer-groups, /collections, and /gift-cards.

**UI**
- Adds new sizes for Badge component.

**Note for review**
Since this PR contains updates to the translation keys, it touches a lot of files. For the review the parts that are relevant are: the /gift-cards domain of admin, the table overview of collections, customers, and customer groups. And the changes to the list endpoints in the core.
This commit is contained in:
Kasper Fabricius Kristensen
2024-02-12 14:47:37 +01:00
committed by GitHub
parent bc2a63782b
commit d37ff8024d
138 changed files with 3230 additions and 1399 deletions

View File

@@ -0,0 +1,51 @@
import { Customer } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import {
EmailCell,
EmailHeader,
} from "../../../components/table/table-cells/common/email-cell"
import {
NameCell,
NameHeader,
} from "../../../components/table/table-cells/common/name-cell"
import {
AccountCell,
AccountHeader,
} from "../../../components/table/table-cells/customer/account-cell/account-cell"
import {
FirstSeenCell,
FirstSeenHeader,
} from "../../../components/table/table-cells/customer/first-seen-cell"
const columnHelper = createColumnHelper<Customer>()
export const useCustomerTableColumns = () => {
return useMemo(
() => [
columnHelper.accessor("email", {
header: () => <EmailHeader />,
cell: ({ getValue }) => <EmailCell email={getValue()} />,
}),
columnHelper.display({
id: "name",
header: () => <NameHeader />,
cell: ({
row: {
original: { first_name, last_name },
},
}) => <NameCell firstName={first_name} lastName={last_name} />,
}),
columnHelper.accessor("has_account", {
header: () => <AccountHeader />,
cell: ({ getValue }) => <AccountCell hasAccount={getValue()} />,
}),
columnHelper.accessor("created_at", {
header: () => <FirstSeenHeader />,
cell: ({ getValue }) => <FirstSeenCell createdAt={getValue()} />,
}),
],
[]
)
}

View File

@@ -0,0 +1,59 @@
import { Product } from "@medusajs/medusa"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import {
CollectionCell,
CollectionHeader,
} from "../../../components/table/table-cells/product/collection-cell/collection-cell"
import {
ProductCell,
ProductHeader,
} from "../../../components/table/table-cells/product/product-cell"
import {
ProductStatusCell,
ProductStatusHeader,
} from "../../../components/table/table-cells/product/product-status-cell"
import {
SalesChannelHeader,
SalesChannelsCell,
} from "../../../components/table/table-cells/product/sales-channels-cell"
import {
VariantCell,
VariantHeader,
} from "../../../components/table/table-cells/product/variant-cell"
const columnHelper = createColumnHelper<Product>()
export const useProductTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "product",
header: () => <ProductHeader />,
cell: ({ row }) => <ProductCell product={row.original} />,
}),
columnHelper.accessor("collection", {
header: () => <CollectionHeader />,
cell: ({ row }) => (
<CollectionCell collection={row.original.collection} />
),
}),
columnHelper.accessor("sales_channels", {
header: () => <SalesChannelHeader />,
cell: ({ row }) => (
<SalesChannelsCell salesChannels={row.original.sales_channels} />
),
}),
columnHelper.accessor("variants", {
header: () => <VariantHeader />,
cell: ({ row }) => <VariantCell variants={row.original.variants} />,
}),
columnHelper.accessor("status", {
header: () => <ProductStatusHeader />,
cell: ({ row }) => <ProductStatusCell status={row.original.status} />,
}),
],
[]
) as ColumnDef<Product>[]
}

View File

@@ -0,0 +1,69 @@
import { useAdminCustomerGroups } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
const excludeableFields = ["groups"] as const
export const useCustomerTableFilters = (
exclude?: (typeof excludeableFields)[number][]
) => {
const { t } = useTranslation()
const isGroupsExcluded = exclude?.includes("groups")
const { customer_groups } = useAdminCustomerGroups(
{
limit: 1000,
expand: "",
},
{
enabled: !isGroupsExcluded,
}
)
let filters: Filter[] = []
if (customer_groups && !isGroupsExcluded) {
const customerGroupFilter: Filter = {
key: "groups",
label: t("customers.groups"),
type: "select",
multiple: true,
options: customer_groups.map((s) => ({
label: s.name,
value: s.id,
})),
}
filters = [...filters, customerGroupFilter]
}
const hasAccountFilter: Filter = {
key: "has_account",
label: t("fields.account"),
type: "select",
options: [
{
label: t("customers.registered"),
value: "true",
},
{
label: t("customers.guest"),
value: "false",
},
],
}
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, hasAccountFilter, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,190 @@
import {
useAdminCollections,
useAdminProductCategories,
useAdminProductTags,
useAdminProductTypes,
useAdminSalesChannels,
} from "medusa-react"
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
const excludeableFields = ["sales_channel_id", "collections"] as const
export const useProductTableFilters = (
exclude?: (typeof excludeableFields)[number][]
) => {
const { t } = useTranslation()
const { product_types } = useAdminProductTypes({
limit: 1000,
offset: 0,
})
const { product_tags } = useAdminProductTags({
limit: 1000,
offset: 0,
})
const isSalesChannelExcluded = exclude?.includes("sales_channel_id")
const { sales_channels } = useAdminSalesChannels(
{
limit: 1000,
fields: "id,name",
expand: "",
},
{
enabled: !isSalesChannelExcluded,
}
)
const { product_categories } = useAdminProductCategories({
limit: 1000,
offset: 0,
fields: "id,name",
expand: "",
})
const isCollectionExcluded = exclude?.includes("collections")
const { collections } = useAdminCollections(
{
limit: 1000,
offset: 0,
},
{
enabled: !isCollectionExcluded,
}
)
let filters: Filter[] = []
if (product_types) {
const typeFilter: Filter = {
key: "type_id",
label: t("fields.type"),
type: "select",
multiple: true,
options: product_types.map((t) => ({
label: t.value,
value: t.id,
})),
}
filters = [...filters, typeFilter]
}
if (product_tags) {
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 (sales_channels) {
const salesChannelFilter: Filter = {
key: "sales_channel_id",
label: t("fields.salesChannel"),
type: "select",
multiple: true,
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,
})),
}
filters = [...filters, salesChannelFilter]
}
if (product_categories) {
const categoryFilter: Filter = {
key: "category_id",
label: t("fields.category"),
type: "select",
multiple: true,
options: product_categories.map((c) => ({
label: c.name,
value: c.id,
})),
}
filters = [...filters, categoryFilter]
}
if (collections) {
const collectionFilter: Filter = {
key: "collection_id",
label: t("fields.collection"),
type: "select",
multiple: true,
options: collections.map((c) => ({
label: c.title,
value: c.id,
})),
}
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 statusFilter: Filter = {
key: "status",
label: t("fields.status"),
type: "select",
multiple: true,
options: [
{
label: t("products.productStatus.draft"),
value: "draft",
},
{
label: t("products.productStatus.proposed"),
value: "proposed",
},
{
label: t("products.productStatus.published"),
value: "published",
},
{
label: t("products.productStatus.rejected"),
value: "rejected",
},
],
}
const dateFilters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
filters = [...filters, statusFilter, giftCardFilter, ...dateFilters]
return filters
}

View File

@@ -0,0 +1,47 @@
import { AdminGetCustomersParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UseCustomerTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useCustomerTableQuery = ({
prefix,
pageSize = 20,
}: UseCustomerTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"q",
"has_account",
"groups",
"order",
"created_at",
"updated_at",
],
prefix
)
const { offset, groups, has_account, q, order } = queryObject
const searchParams: AdminGetCustomersParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
groups: groups?.split(","),
has_account: has_account ? has_account === "true" : undefined,
order,
created_at: queryObject.created_at
? JSON.parse(queryObject.created_at)
: undefined,
updated_at: queryObject.updated_at
? JSON.parse(queryObject.updated_at)
: undefined,
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -6,13 +6,9 @@ type UseOrderTableQueryProps = {
pageSize?: number
}
/**
* TODO: Enable `order` query param when staging is updated
*/
export const useOrderTableQuery = ({
prefix,
pageSize = 50,
pageSize = 20,
}: UseOrderTableQueryProps) => {
const queryObject = useQueryParams(
[
@@ -24,6 +20,7 @@ export const useOrderTableQuery = ({
"sales_channel_id",
"payment_status",
"fulfillment_status",
"order",
],
prefix
)

View File

@@ -0,0 +1,68 @@
import { AdminGetProductsParams } from "@medusajs/medusa"
import { ProductStatus } from "@medusajs/types"
import { useQueryParams } from "../../use-query-params"
type UseProductTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useProductTableQuery = ({
prefix,
pageSize = 20,
}: UseProductTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"order",
"q",
"created_at",
"updated_at",
"sales_channel_id",
"category_id",
"collection_id",
"is_giftcard",
"tags",
"type_id",
"status",
],
prefix
)
const {
offset,
sales_channel_id,
created_at,
updated_at,
category_id,
collection_id,
tags,
type_id,
is_giftcard,
status,
order,
q,
} = queryObject
const searchParams: AdminGetProductsParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
sales_channel_id: sales_channel_id?.split(","),
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
category_id: category_id?.split(","),
collection_id: collection_id?.split(","),
is_giftcard: is_giftcard ? is_giftcard === "true" : undefined,
order: order,
tags: tags?.split(","),
type_id: type_id?.split(","),
status: status?.split(",") as ProductStatus[],
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -10,27 +10,29 @@ import {
import { useEffect, useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
type UseDataTableProps<TData, TValue> = {
type UseDataTableProps<TData> = {
data?: TData[]
columns: ColumnDef<TData, TValue>[]
columns: ColumnDef<TData, any>[]
count?: number
pageSize?: number
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
enablePagination?: boolean
getRowId?: (original: TData, index: number) => string
meta?: Record<string, unknown>
prefix?: string
}
export const useDataTable = <TData, TValue>({
export const useDataTable = <TData,>({
data = [],
columns,
count = 0,
pageSize: _pageSize = 50,
pageSize: _pageSize = 20,
enablePagination = true,
enableRowSelection = false,
getRowId,
meta,
prefix,
}: UseDataTableProps<TData, TValue>) => {
}: UseDataTableProps<TData>) => {
const [searchParams, setSearchParams] = useSearchParams()
const offsetKey = `${prefix ? `${prefix}_` : ""}offset`
const offset = searchParams.get(offsetKey)
@@ -106,6 +108,7 @@ export const useDataTable = <TData, TValue>({
? getPaginationRowModel()
: undefined,
manualPagination: enablePagination ? true : undefined,
meta,
})
return { table }

View File

@@ -8,8 +8,8 @@ export const useFormPrompt = () => {
const promptValues = {
title: t("general.unsavedChangesTitle"),
description: t("general.unsavedChangesDescription"),
cancelText: t("general.cancel"),
confirmText: t("general.continue"),
cancelText: t("actions.cancel"),
confirmText: t("actions.continue"),
}
const prompt = async () => {

View File

@@ -3,13 +3,6 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
type Prompt = {
title: string
description: string
cancelText: string
confirmText: string
}
/**
* Hook for managing the state of route modals.
*/
@@ -30,11 +23,12 @@ export const useRouteModalState = (): [
const prompt = usePrompt()
const { t } = useTranslation()
let promptValues: Prompt = {
const promptValues = {
title: t("general.unsavedChangesTitle"),
description: t("general.unsavedChangesDescription"),
cancelText: t("general.cancel"),
confirmText: t("general.continue"),
cancelText: t("actions.cancel"),
confirmText: t("actions.continue"),
variant: "confirmation" as const,
}
useEffect(() => {