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:
Kasper Fabricius Kristensen
2024-09-04 21:00:25 +02:00
committed by GitHub
parent beaa851302
commit 0fe1201435
1440 changed files with 122 additions and 86 deletions

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./address-form"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./email-form"

View File

@@ -0,0 +1 @@
export * from "./metadata-form"

View File

@@ -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)
)
}

View File

@@ -0,0 +1 @@
export * from "./transfer-ownership-form"

View File

@@ -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>
}