feat: create return reason (#8516)

* feat: create and edit return reasons

* add prop to hide data table header

* make return reasons searchable

* hide table header
This commit is contained in:
Christian
2024-08-12 07:47:07 +02:00
committed by GitHub
parent a19c562bec
commit 4eb2e8379f
18 changed files with 482 additions and 82 deletions

1
.github/teams.yml vendored
View File

@@ -12,3 +12,4 @@
- "@sradevski"
- "@edast"
- "@thetutlage"
- "@christiananese"

View File

@@ -52,6 +52,10 @@ export interface DataTableRootProps<TData> {
* Whether the table is empty due to no results from the active query
*/
noResults?: boolean
/**
* Whether to display the tables header
*/
noHeader?: boolean
/**
* The layout of the table
*/
@@ -80,6 +84,7 @@ export const DataTableRoot = <TData,>({
commands,
count = 0,
noResults = false,
noHeader = false,
layout = "fit",
}: DataTableRootProps<TData>) => {
const { t } = useTranslation()
@@ -133,64 +138,66 @@ export const DataTableRoot = <TData,>({
>
{!noResults ? (
<Table className="relative w-full">
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className={clx({
"relative border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
hasActions,
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
hasSelect,
})}
>
{headerGroup.headers.map((header, index) => {
const isActionHeader = header.id === "actions"
const isSelectHeader = header.id === "select"
const isSpecialHeader = isActionHeader || isSelectHeader
{!noHeader && (
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className={clx({
"relative border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
hasActions,
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
hasSelect,
})}
>
{headerGroup.headers.map((header, index) => {
const isActionHeader = header.id === "actions"
const isSelectHeader = header.id === "select"
const isSpecialHeader = isActionHeader || isSelectHeader
const firstHeader = headerGroup.headers.findIndex(
(h) => h.id !== "select"
)
const isFirstHeader =
firstHeader !== -1
? header.id === headerGroup.headers[firstHeader].id
: index === 0
const firstHeader = headerGroup.headers.findIndex(
(h) => h.id !== "select"
)
const isFirstHeader =
firstHeader !== -1
? header.id === headerGroup.headers[firstHeader].id
: index === 0
const isStickyHeader = isSelectHeader || isFirstHeader
const isStickyHeader = isSelectHeader || isFirstHeader
return (
<Table.HeaderCell
data-table-header-id={header.id}
key={header.id}
style={{
width: !isSpecialHeader
? `${colWidth}%`
: undefined,
}}
className={clx({
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyHeader,
"left-[68px]":
isStickyHeader && hasSelect && !isSelectHeader,
"after:bg-ui-border-base":
showStickyBorder &&
isStickyHeader &&
!isSpecialHeader,
})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
return (
<Table.HeaderCell
data-table-header-id={header.id}
key={header.id}
style={{
width: !isSpecialHeader
? `${colWidth}%`
: undefined,
}}
className={clx({
"bg-ui-bg-base sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isStickyHeader,
"left-[68px]":
isStickyHeader && hasSelect && !isSelectHeader,
"after:bg-ui-border-base":
showStickyBorder &&
isStickyHeader &&
!isSpecialHeader,
})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
)}
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => {
const to = navigateTo ? navigateTo(row) : undefined

View File

@@ -32,6 +32,7 @@ export const DataTable = <TData,>({
queryObject = {},
pageSize,
isLoading = false,
noHeader = false,
layout = "fit",
noRecords: noRecordsProps = {},
}: DataTableProps<TData>) => {
@@ -84,6 +85,7 @@ export const DataTable = <TData,>({
navigateTo={navigateTo}
commands={commands}
noResults={noResults}
noHeader={noHeader}
layout={layout}
/>
</div>

View File

@@ -1,25 +1,30 @@
import { HttpTypes } from "@medusajs/types"
import { Badge } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { TextCell } from "../../../components/table/table-cells/common/text-cell"
const columnHelper = createColumnHelper<HttpTypes.AdminReturnReason>()
export const useReturnReasonTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("value", {
header: () => t("fields.value"),
cell: ({ getValue }) => <TextCell text={getValue()} />,
cell: ({ getValue }) => <Badge size="2xsmall">{getValue()}</Badge>,
}),
columnHelper.accessor("label", {
header: () => t("fields.createdAt"),
cell: ({ getValue }) => <TextCell text={getValue()} />,
cell: ({ row }) => {
const { label, description } = row.original
return (
<div className="flex h-full w-full flex-col justify-center py-4">
<span className="truncate font-medium">{label}</span>
<span className="truncate">
{description ? description : "-"}
</span>
</div>
)
},
}),
],
[t]
[]
)
}

View File

@@ -166,7 +166,8 @@
"goToPublishableApiKeys": "Publishable API Keys",
"goToSecretApiKeys": "Secret API Keys",
"goToWorkflows": "Workflows",
"goToProfile": "Profile"
"goToProfile": "Profile",
"goToReturnReasons": "Return reasons"
}
},
"menus": {
@@ -2104,6 +2105,7 @@
},
"returnReasons": {
"domain": "Return Reasons",
"subtitle": "Manage reasons for returned items.",
"calloutHint": "Manage the reasons to categorize returns.",
"editReason": "Edit Return Reason",
"create": {
@@ -2113,6 +2115,8 @@
"successToast": "Return reason {{label}} was successfully created."
},
"edit": {
"header": "Edit Return Reason",
"subtitle": "Edit the value of the return reason.",
"successToast": "Return reason {{label}} was successfully updated."
},
"delete": {

View File

@@ -264,6 +264,14 @@ export const useGlobalShortcuts = () => {
type: "settingShortcut",
to: "/settings/locations",
},
{
keys: {
Mac: ["G", ",", "M"],
},
label: t("app.keyboardShortcuts.settings.goToReturnReasons"),
type: "settingShortcut",
to: "/settings/return-reasons",
},
{
keys: {
Mac: ["G", ",", "J"],

View File

@@ -1304,6 +1304,28 @@ export const RouteMap: RouteObject[] = [
path: "",
lazy: () =>
import("../../routes/return-reasons/return-reason-list"),
children: [
{
path: "create",
lazy: () =>
import(
"../../routes/return-reasons/return-reason-create"
),
},
{
path: ":id",
children: [
{
path: "edit",
lazy: () =>
import(
"../../routes/return-reasons/return-reason-edit"
),
},
],
},
],
},
],
},

View File

@@ -0,0 +1 @@
export * from "./return-reason-create-form"

View File

@@ -0,0 +1,156 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text, Textarea, 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 { useCreateReturnReason } from "../../../../../hooks/api/return-reasons"
const ReturnReasonCreateSchema = z.object({
value: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
})
export const ReturnReasonCreateForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof ReturnReasonCreateSchema>>({
defaultValues: {
value: "",
label: "",
description: "",
},
resolver: zodResolver(ReturnReasonCreateSchema),
})
const { mutateAsync, isPending } = useCreateReturnReason()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ return_reason }) => {
toast.success(
t("returnReasons.create.successToast", {
label: return_reason.label,
})
)
handleSuccess(`../`)
},
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("returnReasons.create.header")}</Heading>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<Text size="small" className="text-ui-fg-subtle">
{t("returnReasons.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
tooltip={t("returnReasons.fields.value.tooltip")}
>
{t("returnReasons.fields.value.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t(
"returnReasons.fields.value.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="label"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("returnReasons.fields.label.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t(
"returnReasons.fields.label.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("returnReasons.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={t(
"returnReasons.fields.description.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</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 { ReturnReasonCreate as Component } from "./return-reason-create"

View File

@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/modals"
import { ReturnReasonCreateForm } from "./components/return-reason-create-form"
export const ReturnReasonCreate = () => {
return (
<RouteFocusModal>
<ReturnReasonCreateForm />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1 @@
export * from "./return-reason-edit-form"

View File

@@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Input, Textarea, 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 { useUpdateReturnReason } from "../../../../../hooks/api/return-reasons"
type ReturnReasonEditFormProps = {
returnReason: HttpTypes.AdminReturnReason
}
const ReturnReasonEditSchema = z.object({
value: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
})
export const ReturnReasonEditForm = ({
returnReason,
}: ReturnReasonEditFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof ReturnReasonEditSchema>>({
defaultValues: {
value: returnReason.value,
label: returnReason.label,
description: returnReason.description ?? undefined,
},
resolver: zodResolver(ReturnReasonEditSchema),
})
const { mutateAsync, isPending } = useUpdateReturnReason(returnReason.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ return_reason }) => {
toast.success(
t("returnReasons.edit.successToast", {
label: return_reason.label,
})
)
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 gap-y-4 overflow-auto">
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label tooltip={t("returnReasons.fields.value.tooltip")}>
{t("returnReasons.fields.value.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t("returnReasons.fields.value.placeholder")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="label"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("returnReasons.fields.label.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t("returnReasons.fields.label.placeholder")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("returnReasons.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={t(
"returnReasons.fields.description.placeholder"
)}
/>
</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 { ReturnReasonEdit as Component } from "./return-reason-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 { useReturnReason } from "../../../hooks/api/return-reasons"
import { ReturnReasonEditForm } from "./components/return-reason-edit-form"
export const ReturnReasonEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { return_reason, isPending, isError, error } = useReturnReason(id!)
const ready = !isPending && !!return_reason
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("returnReasons.edit.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("returnReasons.edit.subtitle")}
</RouteDrawer.Description>
</RouteDrawer.Header>
{ready && <ReturnReasonEditForm returnReason={return_reason} />}
</RouteDrawer>
)
}

View File

@@ -1,11 +1,11 @@
import { Trash } from "@medusajs/icons"
import { PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui"
import { Button, Container, Heading, Text } 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 { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
@@ -14,7 +14,6 @@ import { useReturnReasonTableColumns } from "../../../../../hooks/table/columns"
import { useReturnReasonTableQuery } from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteReturnReasonAction } from "../../../common/hooks/use-delete-return-reason-action"
import { returnReasonListLoader } from "../../loader"
const PAGE_SIZE = 20
@@ -24,14 +23,9 @@ export const ReturnReasonListTable = () => {
pageSize: PAGE_SIZE,
})
const initialData = useLoaderData() as Awaited<
ReturnType<typeof returnReasonListLoader>
>
const { return_reasons, count, isPending, isError, error } = useReturnReasons(
searchParams,
{
initialData,
placeholderData: keepPreviousData,
}
)
@@ -53,7 +47,12 @@ export const ReturnReasonListTable = () => {
return (
<Container className="divide-y px-0 py-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("returnReasons.domain")}</Heading>
<div>
<Heading>{t("returnReasons.domain")}</Heading>
<Text className="text-ui-fg-subtle" size="small">
{t("returnReasons.subtitle")}
</Text>
</div>
<Button variant="secondary" size="small" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
@@ -65,7 +64,9 @@ export const ReturnReasonListTable = () => {
isLoading={isPending}
columns={columns}
pageSize={PAGE_SIZE}
noHeader={true}
pagination
search
/>
</Container>
)
@@ -84,7 +85,7 @@ const ReturnReasonRowActions = ({
return (
<ActionMenu
groups={[
/* {
{
actions: [
{
icon: <PencilSquare />,
@@ -92,7 +93,7 @@ const ReturnReasonRowActions = ({
to: `${returnReason.id}/edit`,
},
],
}, */
},
{
actions: [
{

View File

@@ -2489,25 +2489,29 @@ export interface FilterableOrderItemProps
*/
export interface FilterableOrderReturnReasonProps
extends BaseFilterable<FilterableOrderReturnReasonProps> {
/**
* Find return reasons through this search term
*/
q?: string
/**
* The IDs to filter the return reasons by.
*/
id?: string | string[]
id?: string | string[] | OperatorMap<string | string[]>
/**
* Filter the return reason by their value.
*/
value?: string | string[]
value?: string | string[] | OperatorMap<string | string[]>
/**
* Filter the return reason by their label.
*/
label?: string
label?: string | OperatorMap<string>
/**
* Filter the return reason by their description.
*/
description?: string
description?: string | OperatorMap<string>
}
/**

View File

@@ -1,6 +1,7 @@
import { DAL } from "@medusajs/types"
import {
DALUtils,
Searchable,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -46,10 +47,12 @@ export default class ReturnReason {
@PrimaryKey({ columnType: "text" })
id: string
@Searchable()
@Property({ columnType: "text" })
@ValueIndex.MikroORMIndex()
value: string
@Searchable()
@Property({ columnType: "text" })
label: string
@@ -67,7 +70,7 @@ export default class ReturnReason {
cascade: [Cascade.PERSIST],
})
parent_return_reason?: Rel<ReturnReason> | null
Searchable
@OneToMany(
() => ReturnReason,
(return_reason) => return_reason.parent_return_reason,