feat(dashboard, js-sdk, medusa, tax, types): custom tax providers (#12297)

* wip: setup loaders, add endpoints, module work, types, js sdk

* fix: tax module provider loader

* feat: select provider on region create, fix enpoint middleware registration

* feat: edit form

* fix: rename param

* chore: changeset

* fix: don't default to system provider

* fix: admin fixes, dispalt tax provider

* fix: some tests and types

* fix: remove provider from province regions in test

* fix: more tests, optional provider for sublevel regions, fix few types

* fix: OE test

* feat: edit tax region admin, update tax region core flow changes

* feat: migrate script

* fix: refactor

* chore: use query graph

* feat: provider section
This commit is contained in:
Frane Polić
2025-05-06 19:26:33 +02:00
committed by GitHub
parent 405ee7f7f3
commit 9cedeb182d
48 changed files with 847 additions and 116 deletions
@@ -2,11 +2,20 @@ import { HttpTypes } from "@medusajs/types"
import { Heading, Text, Tooltip, clx } from "@medusajs/ui"
import ReactCountryFlag from "react-country-flag"
import { ExclamationCircle, MapPin, Plus, Trash } from "@medusajs/icons"
import {
ExclamationCircle,
MapPin,
Plus,
Trash,
PencilSquare,
} from "@medusajs/icons"
import { ComponentPropsWithoutRef, ReactNode } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
Action,
ActionMenu,
} from "../../../../../components/common/action-menu"
import { IconAvatar } from "../../../../../components/common/icon-avatar"
import { getCountryByIso2 } from "../../../../../lib/data/countries"
import {
@@ -107,7 +116,9 @@ export const TaxRegionCard = ({
{name}
</Text>
) : (
<Heading>{name}</Heading>
<div className="flex items-center gap-x-2">
<Heading>{name}</Heading>
</div>
)}
</div>
</div>
@@ -164,7 +175,9 @@ const TaxRegionCardActions = ({
}) => {
const { t } = useTranslation()
const to = taxRegion.parent_id
const hasParent = !!taxRegion.parent_id
const to = hasParent
? `/settings/tax-regions/${taxRegion.parent_id}`
: undefined
const handleDelete = useDeleteTaxRegionAction({ taxRegion, to })
@@ -187,12 +200,17 @@ const TaxRegionCardActions = ({
: []),
{
actions: [
!hasParent && {
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/settings/tax-regions/${taxRegion.id}/edit`,
},
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
].filter(Boolean) as unknown as Action[],
},
]}
/>
@@ -14,6 +14,10 @@ import {
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useCreateTaxRegion } from "../../../../../hooks/api"
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { Combobox } from "../../../../../components/inputs/combobox"
import { formatProvider } from "../../../../../lib/format-provider"
import { sdk } from "../../../../../lib/client"
type TaxRegionCreateFormProps = {
parentId?: string
@@ -27,12 +31,23 @@ const TaxRegionCreateSchema = z.object({
value: z.string().optional(),
}),
country_code: z.string().min(1),
provider_id: z.string(),
})
export const TaxRegionCreateForm = ({ parentId }: TaxRegionCreateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const taxProviders = useComboboxData({
queryKey: ["tax_providers"],
queryFn: (params) => sdk.admin.taxProvider.list(params),
getOptions: (data) =>
data.tax_providers.map((provider) => ({
label: formatProvider(provider.id),
value: provider.id,
})),
})
const form = useForm<z.infer<typeof TaxRegionCreateSchema>>({
defaultValues: {
name: "",
@@ -41,6 +56,7 @@ export const TaxRegionCreateForm = ({ parentId }: TaxRegionCreateFormProps) => {
},
code: "",
country_code: "",
provider_id: "",
},
resolver: zodResolver(TaxRegionCreateSchema),
})
@@ -64,6 +80,7 @@ export const TaxRegionCreateForm = ({ parentId }: TaxRegionCreateFormProps) => {
country_code: values.country_code,
parent_id: parentId,
default_tax_rate: defaultRate,
provider_id: values.provider_id,
},
{
onSuccess: ({ tax_region }) => {
@@ -192,6 +209,29 @@ export const TaxRegionCreateForm = ({ parentId }: TaxRegionCreateFormProps) => {
)
}}
/>
<Form.Field
control={form.control}
name="provider_id"
render={({ field }) => (
<Form.Item>
<Form.Label>
{t("taxRegions.fields.taxProvider")}
</Form.Label>
<Form.Control>
<Combobox
{...field}
options={taxProviders.options}
searchValue={taxProviders.searchValue}
onSearchValueChange={
taxProviders.onSearchValueChange
}
fetchNextPage={taxProviders.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
</div>
</div>
@@ -1,15 +1,16 @@
import { useLoaderData, useParams } from "react-router-dom"
import { useState } from "react"
import { SingleColumnPage } from "../../../components/layout/pages"
import { useTaxRegion } from "../../../hooks/api/tax-regions"
import { TaxRegionDetailSection } from "./components/tax-region-detail-section"
import { TaxRegionProvinceSection } from "./components/tax-region-province-section"
import { useState } from "react"
import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
import { useExtension } from "../../../providers/extension-provider"
import { TaxRegionOverrideSection } from "./components/tax-region-override-section"
import { TaxRegionSublevelAlert } from "./components/tax-region-sublevel-alert"
import { TaxRegionProviderSection } from "./tax-region-provider-section"
import { taxRegionLoader } from "./loader"
export const TaxRegionDetail = () => {
@@ -41,7 +42,7 @@ export const TaxRegionDetail = () => {
<SingleColumnPage
data={taxRegion}
showJSON
// showMetadata // TOOD -> enable when tax region update is added to the API
// showMetadata // TOOD -> enable tax region metadata
widgets={{
after: getWidgets("tax.details.after"),
before: getWidgets("tax.details.before"),
@@ -58,6 +59,7 @@ export const TaxRegionDetail = () => {
showSublevelRegions={showSublevelRegions}
/>
<TaxRegionOverrideSection taxRegion={taxRegion} />
<TaxRegionProviderSection taxRegion={taxRegion} />
</SingleColumnPage>
)
}
@@ -0,0 +1 @@
export * from "./tax-region-provider-section"
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { formatProvider } from "../../../../lib/format-provider"
export function TaxRegionProviderSection({
taxRegion,
}: {
taxRegion: HttpTypes.AdminTaxRegion
}) {
const { t } = useTranslation()
return (
<Container className="divide-y p-0">
<Heading level="h2" className="px-6 py-4">
{t("taxRegions.provider.header")}
</Heading>
<div className="px-6 py-4">
{taxRegion.provider_id && (
<span className="text-ui-fg-subtle">
{formatProvider(taxRegion.provider_id!)}
</span>
)}
</div>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./tax-region-edit-form"
@@ -0,0 +1,109 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, 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 { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../lib/client"
import { Combobox } from "../../../../../components/inputs/combobox"
import { formatProvider } from "../../../../../lib/format-provider"
import { useUpdateTaxRegion } from "../../../../../hooks/api"
type TaxRegionEditFormProps = {
taxRegion: HttpTypes.AdminTaxRegion
}
const TaxRegionEditSchema = z.object({
provider_id: z.string().min(1),
})
export const TaxRegionEditForm = ({ taxRegion }: TaxRegionEditFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const taxProviders = useComboboxData({
queryKey: ["tax_providers"],
queryFn: (params) => sdk.admin.taxProvider.list(params),
getOptions: (data) =>
data.tax_providers.map((provider) => ({
label: formatProvider(provider.id),
value: provider.id,
})),
})
const form = useForm<z.infer<typeof TaxRegionEditSchema>>({
defaultValues: {
provider_id: taxRegion.provider_id,
},
resolver: zodResolver(TaxRegionEditSchema),
})
const { mutateAsync, isPending } = useUpdateTaxRegion(taxRegion.id)
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
provider_id: values.provider_id,
},
{
onSuccess: () => {
toast.success(t("taxRegions.edit.successToast"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex flex-1 flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-6 overflow-auto">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="provider_id"
render={({ field }) => (
<Form.Item>
<Form.Label>{t("taxRegions.fields.taxProvider")}</Form.Label>
<Form.Control>
<Combobox
{...field}
options={taxProviders.options}
searchValue={taxProviders.searchValue}
onSearchValueChange={taxProviders.onSearchValueChange}
fetchNextPage={taxProviders.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer className="shrink-0">
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export { TaxRegionEdit as Component } from "./tax-region-edit"
@@ -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 { TaxRegionEditForm } from "./components/tax-region-edit"
import { useTaxRegion } from "../../../hooks/api"
export const TaxRegionEdit = () => {
const { t } = useTranslation()
const { id } = useParams()
const { tax_region, isPending, isError, error } = useTaxRegion(id!)
const ready = !isPending && !!tax_region
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("taxRegions.taxRates.edit.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("taxRegions.taxRates.edit.hint")}
</RouteDrawer.Description>
</RouteDrawer.Header>
{ready && <TaxRegionEditForm taxRegion={tax_region} />}
</RouteDrawer>
)
}
@@ -33,6 +33,7 @@ export const TaxRegionProvinceDetailSection = ({
)
}
/>
{defaultRates.map((rate) => {
return <TaxRateLine key={rate.id} taxRate={rate} isSublevelTaxRate />
})}