feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)
**What** - Renames /admin-next -> /admin - Renames @medusajs/admin-sdk -> @medusajs/admin-bundler - Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency. - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`. - Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
committed by
GitHub
parent
beaa851302
commit
0fe1201435
@@ -0,0 +1,213 @@
|
||||
import { Heading, Input, Select, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Control } from "react-hook-form"
|
||||
import { AddressSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
import { CountrySelect } from "../../inputs/country-select"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type AddressFieldValues = z.infer<typeof AddressSchema>
|
||||
|
||||
type AddressFormProps = {
|
||||
control: Control<AddressFieldValues>
|
||||
countries?: HttpTypes.AdminRegionCountry[]
|
||||
layout: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
control,
|
||||
countries,
|
||||
layout,
|
||||
}: AddressFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const style = clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.contactHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="first_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.firstName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="last_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.lastName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="company"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.company")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.phone")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.locationHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_1"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.address")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_2"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.address2")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.city")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="postal_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.postalCode")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="province"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.province")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="country_code"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.country")}</Form.Label>
|
||||
<Form.Control>
|
||||
{countries ? (
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{countries.map((country) => (
|
||||
<Select.Item
|
||||
key={country.iso_2}
|
||||
value={country.iso_2}
|
||||
>
|
||||
{country.display_name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<CountrySelect {...field} ref={ref} onChange={onChange} /> // When no countries are provided, use the country select component that has a built-in list of all countries
|
||||
)}
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./address-form"
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input, clx } from "@medusajs/ui"
|
||||
import { Control } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { EmailSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
type EmailFieldValues = z.infer<typeof EmailSchema>
|
||||
|
||||
type EmailFormProps = {
|
||||
control: Control<EmailFieldValues>
|
||||
layout?: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const EmailForm = ({ control, layout = "stack" }: EmailFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})}
|
||||
>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.email")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./email-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./metadata-form"
|
||||
@@ -0,0 +1,415 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
Heading,
|
||||
IconButton,
|
||||
clx,
|
||||
toast,
|
||||
} from "@medusajs/ui"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
ArrowDownMini,
|
||||
ArrowUpMini,
|
||||
EllipsisVertical,
|
||||
Trash,
|
||||
} from "@medusajs/icons"
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { ComponentPropsWithoutRef, forwardRef, useRef } from "react"
|
||||
import { ConditionalTooltip } from "../../common/conditional-tooltip"
|
||||
import { Form } from "../../common/form"
|
||||
import { InlineTip } from "../../common/inline-tip"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { RouteDrawer, useRouteModal } from "../../modals"
|
||||
|
||||
type MetaDataSubmitHook<TRes> = (
|
||||
params: { metadata?: Record<string, any> | null },
|
||||
callbacks: { onSuccess: () => void; onError: (error: FetchError) => void }
|
||||
) => Promise<TRes>
|
||||
|
||||
type MetadataFormProps<TRes> = {
|
||||
metadata?: Record<string, any> | null
|
||||
hook: MetaDataSubmitHook<TRes>
|
||||
isPending: boolean
|
||||
isMutating: boolean
|
||||
}
|
||||
|
||||
const MetadataFieldSchema = z.object({
|
||||
key: z.string(),
|
||||
disabled: z.boolean().optional(),
|
||||
value: z.any(),
|
||||
})
|
||||
|
||||
const MetadataSchema = z.object({
|
||||
metadata: z.array(MetadataFieldSchema),
|
||||
})
|
||||
|
||||
export const MetadataForm = <TRes,>(props: MetadataFormProps<TRes>) => {
|
||||
const { t } = useTranslation()
|
||||
const { isPending, ...innerProps } = props
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<RouteDrawer.Title asChild>
|
||||
<Heading>{t("metadata.edit.header")}</Heading>
|
||||
</RouteDrawer.Title>
|
||||
<RouteDrawer.Description className="sr-only">
|
||||
{t("metadata.edit.description")}
|
||||
</RouteDrawer.Description>
|
||||
</RouteDrawer.Header>
|
||||
{isPending ? <PlaceholderInner /> : <InnerForm {...innerProps} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const METADATA_KEY_LABEL_ID = "metadata-form-key-label"
|
||||
const METADATA_VALUE_LABEL_ID = "metadata-form-value-label"
|
||||
|
||||
const InnerForm = <TRes,>({
|
||||
metadata,
|
||||
hook,
|
||||
isMutating,
|
||||
}: Omit<MetadataFormProps<TRes>, "isPending">) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const deletedOriginalRows = useRef<string[]>([])
|
||||
const hasUneditableRows = getHasUneditableRows(metadata)
|
||||
|
||||
const form = useForm<z.infer<typeof MetadataSchema>>({
|
||||
defaultValues: {
|
||||
metadata: getDefaultValues(metadata),
|
||||
},
|
||||
resolver: zodResolver(MetadataSchema),
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const parsedData = parseValues(data)
|
||||
|
||||
await hook(
|
||||
{
|
||||
metadata: parsedData,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("metadata.edit.successToast"))
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const { fields, insert, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadata",
|
||||
})
|
||||
|
||||
function deleteRow(index: number) {
|
||||
remove(index)
|
||||
}
|
||||
|
||||
function insertRow(index: number, position: "above" | "below") {
|
||||
insert(index + (position === "above" ? 0 : 1), {
|
||||
key: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-y-auto">
|
||||
<div className="bg-ui-bg-base shadow-elevation-card-rest grid grid-cols-1 divide-y rounded-lg">
|
||||
<div className="bg-ui-bg-subtle grid grid-cols-2 divide-x rounded-t-lg">
|
||||
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
|
||||
<label id={METADATA_KEY_LABEL_ID}>
|
||||
{t("metadata.edit.labels.key")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="txt-compact-small-plus text-ui-fg-subtle px-2 py-1.5">
|
||||
<label id={METADATA_VALUE_LABEL_ID}>
|
||||
{t("metadata.edit.labels.value")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field, index) => {
|
||||
const isDisabled = field.disabled || false
|
||||
let placeholder = "-"
|
||||
|
||||
if (typeof field.value === "object") {
|
||||
placeholder = "{ ... }"
|
||||
}
|
||||
|
||||
if (Array.isArray(field.value)) {
|
||||
placeholder = "[ ... ]"
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalTooltip
|
||||
showTooltip={isDisabled}
|
||||
content={t("metadata.edit.complexRow.tooltip")}
|
||||
key={field.id}
|
||||
>
|
||||
<div className="group/table relative">
|
||||
<div
|
||||
className={clx("grid grid-cols-2 divide-x", {
|
||||
"overflow-hidden rounded-b-lg":
|
||||
index === fields.length - 1,
|
||||
})}
|
||||
>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`metadata.${index}.key`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<GridInput
|
||||
aria-labelledby={METADATA_KEY_LABEL_ID}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
placeholder="Key"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`metadata.${index}.value`}
|
||||
render={({ field: { value, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<GridInput
|
||||
aria-labelledby={METADATA_VALUE_LABEL_ID}
|
||||
{...field}
|
||||
value={isDisabled ? placeholder : value}
|
||||
disabled={isDisabled}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
className={clx(
|
||||
"invisible absolute inset-y-0 -right-2.5 my-auto group-hover/table:visible data-[state='open']:visible",
|
||||
{
|
||||
hidden: isDisabled,
|
||||
}
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
asChild
|
||||
>
|
||||
<IconButton size="2xsmall">
|
||||
<EllipsisVertical />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => insertRow(index, "above")}
|
||||
>
|
||||
<ArrowUpMini className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.insertRowAbove")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => insertRow(index, "below")}
|
||||
>
|
||||
<ArrowDownMini className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.insertRowBelow")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
className="gap-x-2"
|
||||
onClick={() => deleteRow(index)}
|
||||
>
|
||||
<Trash className="text-ui-fg-subtle" />
|
||||
{t("metadata.edit.actions.deleteRow")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ConditionalTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{hasUneditableRows && (
|
||||
<InlineTip
|
||||
variant="warning"
|
||||
label={t("metadata.edit.complexRow.label")}
|
||||
>
|
||||
{t("metadata.edit.complexRow.description")}
|
||||
</InlineTip>
|
||||
)}
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={isMutating}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button size="small" type="submit" isLoading={isMutating}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const GridInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
ComponentPropsWithoutRef<"input">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
{...props}
|
||||
autoComplete="off"
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-base placeholder:text-ui-fg-muted disabled:text-ui-fg-disabled disabled:bg-ui-bg-base bg-transparent px-2 py-1.5 outline-none",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
GridInput.displayName = "MetadataForm.GridInput"
|
||||
|
||||
const PlaceholderInner = () => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<RouteDrawer.Body>
|
||||
<Skeleton className="h-[148ox] w-full rounded-lg" />
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Skeleton className="h-7 w-12 rounded-md" />
|
||||
<Skeleton className="h-7 w-12 rounded-md" />
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EDITABLE_TYPES = ["string", "number", "boolean"]
|
||||
|
||||
function getDefaultValues(
|
||||
metadata?: Record<string, any> | null
|
||||
): z.infer<typeof MetadataFieldSchema>[] {
|
||||
if (!metadata || !Object.keys(metadata).length) {
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return Object.entries(metadata).map(([key, value]) => {
|
||||
if (!EDITABLE_TYPES.includes(typeof value)) {
|
||||
return {
|
||||
key,
|
||||
value: value,
|
||||
disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
let stringValue = value
|
||||
|
||||
if (typeof value !== "string") {
|
||||
stringValue = JSON.stringify(value)
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
value: stringValue,
|
||||
original_key: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseValues(
|
||||
values: z.infer<typeof MetadataSchema>
|
||||
): Record<string, any> | null {
|
||||
const metadata = values.metadata
|
||||
|
||||
const isEmpty =
|
||||
!metadata.length ||
|
||||
(metadata.length === 1 && !metadata[0].key && !metadata[0].value)
|
||||
|
||||
if (isEmpty) {
|
||||
return null
|
||||
}
|
||||
|
||||
const update: Record<string, any> = {}
|
||||
|
||||
metadata.forEach((field) => {
|
||||
let key = field.key
|
||||
let value = field.value
|
||||
const disabled = field.disabled
|
||||
|
||||
if (!key || !value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
update[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
key = key.trim()
|
||||
value = value.trim()
|
||||
|
||||
// We try to cast the value to a boolean or number if possible
|
||||
if (value === "true") {
|
||||
update[key] = true
|
||||
} else if (value === "false") {
|
||||
update[key] = false
|
||||
} else {
|
||||
const parsedNumber = parseFloat(value)
|
||||
if (!isNaN(parsedNumber)) {
|
||||
update[key] = parsedNumber
|
||||
} else {
|
||||
update[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function getHasUneditableRows(metadata?: Record<string, any> | null) {
|
||||
if (!metadata) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Object.values(metadata).some(
|
||||
(value) => !EDITABLE_TYPES.includes(typeof value)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./transfer-ownership-form"
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Select, Text, clx } from "@medusajs/ui"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { format } from "date-fns"
|
||||
import { debounce } from "lodash"
|
||||
import { PropsWithChildren, useCallback, useEffect, useState } from "react"
|
||||
import { Control, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useCustomer } from "../../../hooks/api/customers"
|
||||
import { sdk } from "../../../lib/client"
|
||||
import { getStylizedAmount } from "../../../lib/money-amount-helpers"
|
||||
import {
|
||||
getOrderFulfillmentStatus,
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
import { Combobox } from "../../inputs/combobox"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
type TransferOwnerShipFormProps = {
|
||||
/**
|
||||
* The Order or DraftOrder to transfer ownership of.
|
||||
*/
|
||||
order: HttpTypes.AdminOrder
|
||||
/**
|
||||
* React Hook Form control object.
|
||||
*/
|
||||
control: Control<TransferOwnerShipFieldValues>
|
||||
}
|
||||
|
||||
const isOrder = (
|
||||
order: HttpTypes.AdminOrder
|
||||
): order is HttpTypes.AdminOrder => {
|
||||
return "customer" in order
|
||||
}
|
||||
|
||||
export const TransferOwnerShipForm = ({
|
||||
order,
|
||||
control,
|
||||
}: TransferOwnerShipFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const isOrderType = isOrder(order)
|
||||
const currentOwnerId = useWatch({
|
||||
control,
|
||||
name: "current_owner_id",
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(query)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [query, debouncedUpdate])
|
||||
|
||||
const {
|
||||
customer: owner,
|
||||
isLoading: isLoadingOwner,
|
||||
isError: isOwnerError,
|
||||
error: ownerError,
|
||||
} = useCustomer(currentOwnerId)
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||
queryKey: ["customers", debouncedQuery],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const res = await sdk.admin.customer.list({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
has_account: true, // Only show customers with confirmed accounts
|
||||
})
|
||||
return res
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreCustomersExist =
|
||||
lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const createLabel = (customer?: HttpTypes.AdminCustomer) => {
|
||||
if (!customer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { first_name, last_name, email } = customer
|
||||
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
|
||||
if (name) {
|
||||
return `${name} (${email})`
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
const ownerReady = !isLoadingOwner && owner
|
||||
|
||||
const options =
|
||||
data?.pages
|
||||
.map((p) =>
|
||||
p.customers.map((c) => ({
|
||||
label: createLabel(c),
|
||||
value: c.id,
|
||||
}))
|
||||
)
|
||||
.flat() || []
|
||||
|
||||
if (isOwnerError) {
|
||||
throw ownerError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{isOrderType
|
||||
? t("transferOwnership.details.order")
|
||||
: t("transferOwnership.details.draft")}
|
||||
</Text>
|
||||
{isOrderType ? (
|
||||
<OrderDetailsTable order={order} />
|
||||
) : (
|
||||
<DraftOrderDetailsTable draft={order} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("transferOwnership.currentOwner.label")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("transferOwnership.currentOwner.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
{ownerReady ? (
|
||||
<Select defaultValue={owner.id} disabled>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={owner.id}>{createLabel(owner)}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-full rounded-md" />
|
||||
)}
|
||||
</div>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="new_owner_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col">
|
||||
<Form.Label>{t("transferOwnership.newOwner.label")}</Form.Label>
|
||||
<Form.Hint>{t("transferOwnership.newOwner.hint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OrderDetailsTable = ({ order }: { order: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { label: fulfillmentLabel } = getOrderFulfillmentStatus(
|
||||
t,
|
||||
order.fulfillment_status
|
||||
)
|
||||
|
||||
const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.order")} value={`#${order.display_id}`} />
|
||||
<DateRow date={order.created_at} />
|
||||
<Row label={t("fields.fulfillment")} value={fulfillmentLabel} />
|
||||
<Row label={t("fields.payment")} value={paymentLabel} />
|
||||
<TotalRow total={order.total || 0} currencyCode={order.currency_code} />
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Create type for Draft Order when we have it
|
||||
const DraftOrderDetailsTable = ({ draft }: { draft: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.draft")} value={`#${draft.display_id}`} />
|
||||
<DateRow date={draft.created_at} />
|
||||
<Row
|
||||
label={t("fields.status")}
|
||||
value={t(`draftOrders.status.${draft.status}`)}
|
||||
/>
|
||||
|
||||
{/* TODO: This will likely change. We don't use carts for draft orders any longer. */}
|
||||
{/* <TotalRow
|
||||
total={draft.cart.total || 0}
|
||||
currencyCode={draft.cart.region.currency_code}
|
||||
/> */}
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DateRow = ({ date }: { date: string | Date }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formattedDate = format(new Date(date), "dd MMM yyyy")
|
||||
|
||||
return <Row label={t("fields.date")} value={formattedDate} />
|
||||
}
|
||||
|
||||
const TotalRow = ({
|
||||
total,
|
||||
currencyCode,
|
||||
}: {
|
||||
total: number
|
||||
currencyCode: string
|
||||
}) => {
|
||||
return <Row label="Total" value={getStylizedAmount(total, currencyCode)} />
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<div className="txt-compact-small grid grid-cols-2 divide-x">
|
||||
<div className="text-ui-fg-muted px-2 py-1.5">{label}</div>
|
||||
<div className="text-ui-fg-subtle px-2 py-1.5">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Table = ({ children }: PropsWithChildren) => {
|
||||
return <div className={clx("divide-y rounded-lg border")}>{children}</div>
|
||||
}
|
||||
Reference in New Issue
Block a user