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:
Kasper Fabricius Kristensen
2024-05-03 10:37:36 +00:00
committed by GitHub
parent e42308557e
commit fdee748eed
84 changed files with 2837 additions and 1588 deletions
@@ -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,
}
}