feat(dashboard): Product create from - details (#7121)
**What** - First part of the product creation form. - New components: - ChipInput - Allows users to input chips into a input field. Chips are created by hitting the `,` or `Enter / Return` keys. Deleting a chip is done by hitting `Backspace` when the cursor is next to chip, or clicking the `X` button in the chip. Used for inputting option values. - SortableList - A sortable drag-n-drop list that allows the user to re-arrange the order of items. Used for re-arranging the ranking of variants. - ChipGroup - New re-usable component that is used to render a group of values as Chips. This should be used for SplitView form items. - CategoryCombobox - (WIP) Nested Combobox component for selecting multiple categories a product should be associated with. - New hooks: - useComboboxData - Hook for easily managing the state of comboboxes. - useDebouncedSearch - Hook for managing debounced search queries.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { TriangleRightMini } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { IconButton, Text, clx } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { StatusCell } from "../../../components/table/table-cells/common/status-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../components/table/table-cells/common/text-cell"
|
||||
import {
|
||||
getCategoryPath,
|
||||
getIsActiveProps,
|
||||
getIsInternalProps,
|
||||
} from "../../../v2-routes/categories/common/utils"
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
|
||||
|
||||
export const useCategoryTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: () => <TextHeader text={t("fields.name")} />,
|
||||
cell: ({ getValue, row }) => {
|
||||
const expandHandler = row.getToggleExpandedHandler()
|
||||
|
||||
if (row.original.parent_category !== undefined) {
|
||||
const path = getCategoryPath(row.original)
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-1 overflow-hidden">
|
||||
{path.map((chip, index) => (
|
||||
<div
|
||||
key={chip.id}
|
||||
className={clx("overflow-hidden", {
|
||||
"text-ui-fg-muted flex items-center gap-x-1":
|
||||
index !== path.length - 1,
|
||||
})}
|
||||
>
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{chip.name}
|
||||
</Text>
|
||||
{index !== path.length - 1 && (
|
||||
<Text size="small" leading="compact">
|
||||
/
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-x-3 overflow-hidden">
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{row.getCanExpand() ? (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
expandHandler()
|
||||
}}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx({
|
||||
"rotate-90 transition-transform will-change-transform":
|
||||
row.getIsExpanded(),
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate">{getValue()}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("handle", {
|
||||
header: () => <TextHeader text={t("fields.handle")} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <TextCell text={`/${getValue()}`} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_active", {
|
||||
header: () => <TextHeader text={t("fields.status")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsActiveProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_internal", {
|
||||
header: () => <TextHeader text={t("categories.fields.visibility")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsInternalProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export const useProductTableFilters = (
|
||||
{
|
||||
limit: 1000,
|
||||
fields: "id,name",
|
||||
expand: "",
|
||||
},
|
||||
{
|
||||
enabled: !isSalesChannelExcluded,
|
||||
|
||||
@@ -1,71 +1,91 @@
|
||||
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
|
||||
import debounce from "lodash/debounce"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
QueryKey,
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { useDebouncedSearch } from "./use-debounced-search"
|
||||
|
||||
type Params = {
|
||||
q: string
|
||||
limit: number
|
||||
type ComboboxExternalData = {
|
||||
offset: number
|
||||
}
|
||||
|
||||
type Page = {
|
||||
limit: number
|
||||
count: number
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
type UseComboboxDataProps<TParams extends Params, TRes extends Page> = {
|
||||
fetcher: (params: TParams) => Promise<TRes>
|
||||
params?: Omit<TParams, "q" | "limit" | "offset">
|
||||
queryKey: QueryKey
|
||||
type ComboboxQueryParams = {
|
||||
q?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching infinite data for a combobox.
|
||||
*/
|
||||
export const useComboboxData = <TParams extends Params, TRes extends Page>({
|
||||
fetcher,
|
||||
params,
|
||||
export const useComboboxData = <
|
||||
TResponse extends ComboboxExternalData,
|
||||
TParams extends ComboboxQueryParams
|
||||
>({
|
||||
queryKey,
|
||||
}: UseComboboxDataProps<TParams, TRes>) => {
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
queryFn,
|
||||
getOptions,
|
||||
defaultValue,
|
||||
defaultValueKey,
|
||||
pageSize = 10,
|
||||
}: {
|
||||
queryKey: QueryKey
|
||||
queryFn: (params: TParams) => Promise<TResponse>
|
||||
getOptions: (data: TResponse) => { label: string; value: string }[]
|
||||
defaultValueKey?: keyof TParams
|
||||
defaultValue?: string | string[]
|
||||
pageSize?: number
|
||||
}) => {
|
||||
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
|
||||
|
||||
// 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 data = useInfiniteQuery(
|
||||
[...queryKey, debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await fetcher({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
...params,
|
||||
const queryIntialDataBy = defaultValueKey || "id"
|
||||
const { data: initialData } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: async () => {
|
||||
return queryFn({
|
||||
[queryIntialDataBy]: defaultValue,
|
||||
limit: Array.isArray(defaultValue) ? defaultValue.length : 1,
|
||||
} as TParams)
|
||||
return res
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const morePages = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return morePages ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
enabled: !!defaultValue,
|
||||
})
|
||||
|
||||
const { data, ...rest } = useInfiniteQuery({
|
||||
queryKey: [...queryKey, query],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return queryFn({
|
||||
q: query,
|
||||
limit: pageSize,
|
||||
offset: pageParam,
|
||||
} as TParams)
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const options = data?.pages.flatMap((page) => getOptions(page)) ?? []
|
||||
const defaultOptions = initialData ? getOptions(initialData) : []
|
||||
|
||||
/**
|
||||
* If there are no options and the query is empty, then the combobox should be disabled,
|
||||
* as there is no data to search for.
|
||||
*/
|
||||
const disabled = !rest.isPending && !options.length && !searchValue
|
||||
|
||||
// // make sure that the default value is included in the option, if its not in options already
|
||||
if (defaultValue && !options.find((o) => o.value === defaultValue)) {
|
||||
options.unshift(defaultOptions[0])
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
query,
|
||||
setQuery,
|
||||
options,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
disabled,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import debounce from "lodash/debounce"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* Hook for debouncing search input
|
||||
* @returns searchValue, onSearchValueChange, query
|
||||
*/
|
||||
export const useDebouncedSearch = () => {
|
||||
const [searchValue, onSearchValueChange] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query: string) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(searchValue)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [searchValue, debouncedUpdate])
|
||||
|
||||
return {
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
query: debouncedQuery || undefined,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user