From fdee748eed1707920bc19cf95d51488b864d6291 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 3 May 2024 12:37:36 +0200 Subject: [PATCH] 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. --- packages/admin-next/dashboard/package.json | 10 +- .../public/locales/en-US/translation.json | 43 +- .../common/chip-group/chip-group.tsx | 111 +++ .../src/components/common/chip-group/index.ts | 1 + .../src/components/common/keypair/index.tsx | 1 - .../src/components/common/keypair/keypair.tsx | 149 ---- .../src/components/common/list/index.tsx | 1 - .../src/components/common/list/list.tsx | 54 -- .../common/product-table-cells/index.ts | 1 - .../product-table-cells.tsx | 130 --- .../components/common/sortable-list/index.ts | 1 + .../common/sortable-list/sortable-list.tsx | 228 +++++ .../forms/address-form/address-form.tsx | 2 +- .../transfer-ownership-form.tsx | 2 +- .../inputs/chip-input/chip-input.tsx | 187 ++++ .../src/components/inputs/chip-input/index.ts | 1 + .../{common => inputs}/combobox/combobox.tsx | 2 +- .../{common => inputs}/combobox/index.ts | 0 .../country-select/country-select.tsx | 0 .../country-select/index.ts | 0 .../handle-input/handle-input.tsx | 0 .../{common => inputs}/handle-input/index.ts | 0 .../percentage-input/index.ts | 0 .../percentage-input/percentage-input.tsx | 0 .../data-table-root/data-table-root.tsx | 5 + .../columns/use-category-table-columns.tsx | 114 +++ .../filters/use-product-table-filters.tsx | 1 - .../dashboard/src/hooks/use-combobox-data.tsx | 128 +-- .../src/hooks/use-debounced-search.tsx | 29 + .../dashboard/src/lib/client/common.ts | 1 + .../create-discount-details.tsx | 4 +- .../edit-discount-details-form.tsx | 4 +- .../create-draft-order-customer-details.tsx | 2 +- ...te-draft-order-shipping-method-details.tsx | 2 +- .../variant-table/variant-table.tsx | 12 +- .../category-list-table.tsx | 97 +-- .../create-collection-form.tsx | 2 +- .../components/edit-item-attributes-form.tsx | 8 +- .../category-combobox/category-combobox.tsx | 380 +++++++++ .../components/category-combobox/index.ts | 1 + .../products/common/variant-pricing-form.tsx | 18 +- .../product-attributes-form.tsx | 2 +- .../create-product-option-form.tsx | 33 +- .../create-product-variant-form.tsx | 8 +- .../components/product-attributes-form.tsx | 801 ------------------ .../index.ts | 1 + ...oduct-create-details-attribute-section.tsx | 155 ++++ .../product-create-details-context/index.ts | 2 + .../product-create-details-context.tsx | 9 + .../use-product-create-details-context.tsx | 14 + .../index.ts | 1 + .../product-create-general-section.tsx | 106 +++ .../index.ts | 1 + .../product-create-details-media-section.tsx | 69 ++ .../index.ts | 1 + ...roduct-create-details-organize-section.tsx | 193 +++++ .../index.ts | 1 + ...product-create-details-variant-section.tsx | 428 ++++++++++ .../index.ts | 1 + .../product-create-sales-channel-drawer.tsx | 198 +++++ .../product-create-details-form/index.ts | 1 + .../product-create-details-form.tsx | 61 ++ .../components/product-create-form/index.ts | 1 + .../product-create-form.tsx} | 86 +- .../products/product-create/constants.ts | 78 ++ .../product-create/product-create.tsx | 4 +- .../products/product-create/schema.ts | 82 -- .../products/product-create/types.ts | 4 + .../products/product-create/utils.ts | 51 ++ .../product-edit-variant-form.tsx | 6 +- .../product-organization-form.tsx | 13 +- .../products/product-prices/pricing-edit.tsx | 16 +- .../edit-rules-form/edit-rules-form.tsx | 8 +- .../create-promotion-form.tsx | 24 +- .../edit-promotion-details-form.tsx | 6 +- .../create-region-form/create-region-form.tsx | 2 +- .../edit-region-form/edit-region-form.tsx | 8 +- .../create-reservation-form.tsx | 12 +- .../create-location-form.tsx | 2 +- .../edit-location-form/edit-location-form.tsx | 2 +- .../tax-region-create-form.tsx | 6 +- .../tax-rate-create-form.tsx | 26 +- .../tax-rate-edit-form/tax-rate-edit-form.tsx | 20 +- yarn.lock | 140 ++- 84 files changed, 2832 insertions(+), 1583 deletions(-) create mode 100644 packages/admin-next/dashboard/src/components/common/chip-group/chip-group.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/chip-group/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/common/keypair/index.tsx delete mode 100644 packages/admin-next/dashboard/src/components/common/keypair/keypair.tsx delete mode 100644 packages/admin-next/dashboard/src/components/common/list/index.tsx delete mode 100644 packages/admin-next/dashboard/src/components/common/list/list.tsx delete mode 100644 packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-list/index.ts create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-list/sortable-list.tsx create mode 100644 packages/admin-next/dashboard/src/components/inputs/chip-input/chip-input.tsx create mode 100644 packages/admin-next/dashboard/src/components/inputs/chip-input/index.ts rename packages/admin-next/dashboard/src/components/{common => inputs}/combobox/combobox.tsx (99%) rename packages/admin-next/dashboard/src/components/{common => inputs}/combobox/index.ts (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/country-select/country-select.tsx (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/country-select/index.ts (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/handle-input/handle-input.tsx (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/handle-input/index.ts (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/percentage-input/index.ts (100%) rename packages/admin-next/dashboard/src/components/{common => inputs}/percentage-input/percentage-input.tsx (100%) create mode 100644 packages/admin-next/dashboard/src/hooks/table/columns/use-category-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/hooks/use-debounced-search.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/category-combobox.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/index.ts delete mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-attributes-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-attribute-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-attribute-section/product-create-details-attribute-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-context/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-context/product-create-details-context.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-context/use-product-create-details-context.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-general-section/product-create-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-media-section/product-create-details-media-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-organize-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-organize-section/product-create-details-organize-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-details-variant-section/product-create-details-variant-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-sales-channel-drawer/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/components/product-create-sales-channel-drawer/product-create-sales-channel-drawer.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-details-form/product-create-details-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-create-form/index.ts rename packages/admin-next/dashboard/src/v2-routes/products/product-create/components/{create-product.tsx => product-create-form/product-create-form.tsx} (70%) create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/constants.ts delete mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/schema.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/types.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create/utils.ts diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 84b848f6d72d..1fd5ac335566 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -18,6 +18,8 @@ ], "dependencies": { "@ariakit/react": "^0.4.1", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "3.3.2", "@medusajs/icons": "workspace:^", "@medusajs/ui": "workspace:^", @@ -36,10 +38,10 @@ "match-sorter": "^6.3.4", "medusa-react": "workspace:^", "qs": "^6.12.0", - "react": "18.2.0", + "react": "^18.2.0", "react-country-flag": "^3.1.0", "react-currency-input-field": "^3.6.11", - "react-dom": "18.2.0", + "react-dom": "^18.2.0", "react-hook-form": "7.49.1", "react-i18next": "13.5.0", "react-jwt": "^1.2.0", @@ -53,8 +55,8 @@ "@medusajs/ui-preset": "workspace:^", "@medusajs/vite-plugin-extension": "workspace:^", "@types/node": "^20.11.15", - "@types/react": "18.2.43", - "@types/react-dom": "18.2.17", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "4.2.1", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index ed5d2824e3c1..0c8f60beec99 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -41,14 +41,13 @@ "typeToConfirm": "Please type {val} to confirm:", "noResultsTitle": "No results", "noResultsMessage": "Try changing the filters or search query", + "noSearchResults": "No search results", + "noSearchResultsFor": "No search results for <0>'{{query}}'", "noRecordsTitle": "No records", "noRecordsMessage": "There are no records to show", "unsavedChangesTitle": "Are you sure you want to leave this page?", "unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.", - "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.", - "timeline": "Timeline", - "success": "Success", - "error": "Error" + "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved." }, "validation": { "mustBeInt": "The value must be a whole number.", @@ -162,8 +161,27 @@ }, "products": { "domain": "Products", - "createProductTitle": "Create Product", - "createProductHint": "Create a new product to sell in your store.", + "create": { + "header": "Create Product", + "hint": "Create a new product to sell in your store.", + "tabs": { + "details": "Details", + "variants": "Variants" + }, + "variants": { + "header": "Variants", + "productVariants": { + "label": "Product variants", + "hint": "Variants left unchecked won't be created. This ranking will affect how the variants are ranked in your frontend.", + "alert": "Add options to create variants." + }, + "productOptions": { + "label": "Product options", + "hint": "Define the options for the product, e.g. color, size, etc." + } + }, + "successToast": "Product {{title}} was successfully created." + }, "deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.", "variants": "Variants", "attributes": "Attributes", @@ -296,10 +314,12 @@ "options": { "header": "Options", "edit": { - "header": "Edit Option" + "header": "Edit Option", + "successToast": "Option {{title}} was successfully updated." }, "create": { - "header": "Create Option" + "header": "Create Option", + "successToast": "Option {{title}} was successfully created." } }, "toasts": { @@ -349,11 +369,11 @@ "associatedVariants": "Associated variants", "manageLocations": "Manage locations", "deleteWarning": "You are about to delete an inventory item. This action cannot be undone.", - "reservation": { + "reservation": { "header": "Reservation of {{itemName}}", "editItemDetails": "Edit item details", "orderID": "Order ID", - "description": "Description", + "description": "Description", "location": "Location", "inStockAtLocation": "In stock at this location", "availableAtLocation": "Available at this location", @@ -1579,7 +1599,8 @@ "unitPrice": "Unit price", "startDate": "Start date", "endDate": "End date", - "draft": "Draft" + "draft": "Draft", + "values": "Values" }, "metadata": { "warnings": { diff --git a/packages/admin-next/dashboard/src/components/common/chip-group/chip-group.tsx b/packages/admin-next/dashboard/src/components/common/chip-group/chip-group.tsx new file mode 100644 index 000000000000..466a3a5943c4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/chip-group/chip-group.tsx @@ -0,0 +1,111 @@ +import { XMarkMini } from "@medusajs/icons" +import { Button, clx } from "@medusajs/ui" +import { Children, PropsWithChildren, createContext, useContext } from "react" +import { useTranslation } from "react-i18next" + +type ChipGroupVariant = "base" | "component" + +type ChipGroupProps = PropsWithChildren<{ + onClearAll?: () => void + onRemove?: (index: number) => void + variant?: ChipGroupVariant + className?: string +}> + +type GroupContextValue = { + onRemove?: (index: number) => void + variant: ChipGroupVariant +} + +const GroupContext = createContext(null) + +const useGroupContext = () => { + const context = useContext(GroupContext) + + if (!context) { + throw new Error("useGroupContext must be used within a ChipGroup component") + } + + return context +} + +const Group = ({ + onClearAll, + onRemove, + variant = "component", + className, + children, +}: ChipGroupProps) => { + const { t } = useTranslation() + + const showClearAll = !!onClearAll && Children.count(children) > 0 + + return ( + + + + ) +} + +type ChipProps = PropsWithChildren<{ + index: number + className?: string +}> + +const Chip = ({ index, className, children }: ChipProps) => { + const { onRemove, variant } = useGroupContext() + + return ( +
  • + + {children} + + {!!onRemove && ( + + )} +
  • + ) +} + +export const ChipGroup = Object.assign(Group, { Chip }) diff --git a/packages/admin-next/dashboard/src/components/common/chip-group/index.ts b/packages/admin-next/dashboard/src/components/common/chip-group/index.ts new file mode 100644 index 000000000000..c919f6cc87cf --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/chip-group/index.ts @@ -0,0 +1 @@ +export * from "./chip-group" diff --git a/packages/admin-next/dashboard/src/components/common/keypair/index.tsx b/packages/admin-next/dashboard/src/components/common/keypair/index.tsx deleted file mode 100644 index 5215aa198893..000000000000 --- a/packages/admin-next/dashboard/src/components/common/keypair/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./keypair" diff --git a/packages/admin-next/dashboard/src/components/common/keypair/keypair.tsx b/packages/admin-next/dashboard/src/components/common/keypair/keypair.tsx deleted file mode 100644 index 160b02000d86..000000000000 --- a/packages/admin-next/dashboard/src/components/common/keypair/keypair.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Plus, Trash } from "@medusajs/icons" -import { Button, Input, Table } from "@medusajs/ui" -import { useState } from "react" - -interface KeyPair { - key: string - value: string -} - -export interface KeypairProps { - labels: { - add: string - key?: string - value?: string - } - value: KeyPair[] - onChange: (value: KeyPair[]) => void - disabled?: boolean -} - -export const Keypair = ({ labels, onChange, value }: KeypairProps) => { - const addKeyPair = () => { - onChange([...value, { key: ``, value: `` }]) - } - - const deleteKeyPair = (index: number) => { - return () => { - onChange(value.filter((_, i) => i !== index)) - } - } - - const onKeyChange = (index: number) => { - return (key: string) => { - const newArr = value.map((pair, i) => { - if (i === index) { - return { key, value: pair.value } - } - return pair - }) - - onChange(newArr) - } - } - - const onValueChange = (index: number) => { - return (val: string) => { - const newArr = value.map((pair, i) => { - if (i === index) { - return { key: pair.key, value: val } - } - return pair - }) - - onChange(newArr) - } - } - - return ( -
    - - - - {labels.key} - {labels.value} - - - - {value.map((pair, index) => { - return ( - - ) - })} - -
    - -
    - ) -} - -type FieldProps = { - field: KeyPair - labels: { - key?: string - value?: string - } - updateKey: (key: string) => void - updateValue: (value: string) => void - onDelete: () => void -} - -const Field: React.FC = ({ - field, - updateKey, - updateValue, - onDelete, -}) => { - const [key, setKey] = useState(field.key) - const [value, setValue] = useState(field.value) - - return ( - - - updateKey(key)} - value={key} - onChange={(e) => { - setKey(e.currentTarget.value) - }} - /> - - - updateValue(value)} - value={value} - onChange={(e) => { - setValue(e.currentTarget.value) - }} - /> - - - - - - ) -} diff --git a/packages/admin-next/dashboard/src/components/common/list/index.tsx b/packages/admin-next/dashboard/src/components/common/list/index.tsx deleted file mode 100644 index 252d7e2efc4c..000000000000 --- a/packages/admin-next/dashboard/src/components/common/list/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./list" diff --git a/packages/admin-next/dashboard/src/components/common/list/list.tsx b/packages/admin-next/dashboard/src/components/common/list/list.tsx deleted file mode 100644 index a058177d05a2..000000000000 --- a/packages/admin-next/dashboard/src/components/common/list/list.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Checkbox, Text } from "@medusajs/ui" - -export interface ListProps { - options: { title: string; value: T }[] - value?: T[] - onChange?: (value: T[]) => void - compare?: (a: T, b: T) => boolean - disabled?: boolean -} - -export const List = ({ - options, - onChange, - value, - compare, - disabled, -}: ListProps) => { - if (options.length === 0) { - return null - } - - return ( -
    - {options.map((option) => { - return ( -
    - {onChange && value !== undefined && ( - compare?.(v, option.value) ?? v === option.value - )} - onCheckedChange={(checked) => { - if (checked) { - onChange([...value, option.value]) - } else { - onChange( - value.filter( - (v) => - !(compare?.(v, option.value) ?? v === option.value) - ) - ) - } - }} - /> - )} - - {option.title} -
    - ) - })} -
    - ) -} diff --git a/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts b/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts deleted file mode 100644 index 8e79962ee024..000000000000 --- a/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./product-table-cells" diff --git a/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx b/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx deleted file mode 100644 index f29d4d8abdcd..000000000000 --- a/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { SalesChannel } from "@medusajs/medusa" -import { - ProductCollectionDTO, - ProductDTO, - ProductVariantDTO, -} from "@medusajs/types" -import { StatusBadge, Text } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { Thumbnail } from "../thumbnail" - -export const ProductVariantCell = ({ - variants, -}: { - variants: ProductVariantDTO[] | null -}) => { - const { t } = useTranslation() - - if (!variants || !variants.length) { - return ( - - - - - ) - } - - return ( - - {t("products.variantCount", { - count: variants.length, - })} - - ) -} - -export const ProductStatusCell = ({ - status, -}: { - status: ProductDTO["status"] -}) => { - const { t } = useTranslation() - - const color = { - draft: "grey", - published: "green", - rejected: "red", - proposed: "blue", - }[status] as "grey" | "green" | "red" | "blue" - - return ( - - {t(`products.productStatus.${status}`)} - - ) -} - -export const ProductAvailabilityCell = ({ - salesChannels, -}: { - salesChannels: SalesChannel[] | null -}) => { - const { t } = useTranslation() - - if (!salesChannels || salesChannels.length === 0) { - return ( - - - - - ) - } - - if (salesChannels.length < 3) { - return ( - - {salesChannels.map((sc) => sc.name).join(", ")} - - ) - } - - return ( -
    - - - {salesChannels - .slice(0, 2) - .map((sc) => sc.name) - .join(", ")} - {" "} - - {t("general.plusCountMore", { - count: salesChannels.length - 2, - })} - - -
    - ) -} - -export const ProductTitleCell = ({ product }: { product: ProductDTO }) => { - const thumbnail = product.thumbnail - const title = product.title - - return ( -
    - - - {title} - -
    - ) -} - -export const ProductCollectionCell = ({ - collection, -}: { - collection: ProductCollectionDTO | null -}) => { - if (!collection) { - return ( - - - - - ) - } - - return ( - - {collection.title} - - ) -} diff --git a/packages/admin-next/dashboard/src/components/common/sortable-list/index.ts b/packages/admin-next/dashboard/src/components/common/sortable-list/index.ts new file mode 100644 index 000000000000..c9eb55bec3a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-list/index.ts @@ -0,0 +1 @@ +export * from "./sortable-list" diff --git a/packages/admin-next/dashboard/src/components/common/sortable-list/sortable-list.tsx b/packages/admin-next/dashboard/src/components/common/sortable-list/sortable-list.tsx new file mode 100644 index 000000000000..e588fc8bca27 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-list/sortable-list.tsx @@ -0,0 +1,228 @@ +import { + Active, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + DraggableSyntheticListeners, + KeyboardSensor, + PointerSensor, + defaultDropAnimationSideEffects, + useSensor, + useSensors, + type DropAnimation, + type UniqueIdentifier, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { DotsSix } from "@medusajs/icons" +import { IconButton, clx } from "@medusajs/ui" +import { + CSSProperties, + Fragment, + PropsWithChildren, + ReactNode, + createContext, + useContext, + useMemo, + useState, +} from "react" + +type SortableBaseItem = { + id: UniqueIdentifier +} + +interface SortableListProps { + items: TItem[] + onChange: (items: TItem[]) => void + renderItem: (item: TItem, index: number) => ReactNode +} + +const List = ({ + items, + onChange, + renderItem, +}: SortableListProps) => { + const [active, setActive] = useState(null) + + const [activeItem, activeIndex] = useMemo(() => { + if (active === null) { + return [null, null] + } + + const index = items.findIndex(({ id }) => id === active.id) + + return [items[index], index] + }, [active, items]) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = ({ active }: DragStartEvent) => { + setActive(active) + } + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + const activeIndex = items.findIndex(({ id }) => id === active.id) + const overIndex = items.findIndex(({ id }) => id === over.id) + + onChange(arrayMove(items, activeIndex, overIndex)) + } + + setActive(null) + } + + const handleDragCancel = () => { + setActive(null) + } + + return ( + + + {activeItem && activeIndex !== null + ? renderItem(activeItem, activeIndex) + : null} + + +
      + {items.map((item, index) => ( + {renderItem(item, index)} + ))} +
    +
    +
    + ) +} + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +type SortableOverlayProps = PropsWithChildren + +const Overlay = ({ children }: SortableOverlayProps) => { + return ( + + {children} + + ) +} + +type SortableItemProps = PropsWithChildren<{ + id: TItem["id"] + className?: string +}> + +type SortableItemContextValue = { + attributes: Record + listeners: DraggableSyntheticListeners + ref: (node: HTMLElement | null) => void + isDragging: boolean +} + +const SortableItemContext = createContext(null) + +const useSortableItemContext = () => { + const context = useContext(SortableItemContext) + + if (!context) { + throw new Error( + "useSortableItemContext must be used within a SortableItemContext" + ) + } + + return context +} + +const Item = ({ + id, + className, + children, +}: SortableItemProps) => { + const { + attributes, + isDragging, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + } = useSortable({ id }) + + const context = useMemo( + () => ({ + attributes, + listeners, + ref: setActivatorNodeRef, + isDragging, + }), + [attributes, listeners, setActivatorNodeRef, isDragging] + ) + + const style: CSSProperties = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Translate.toString(transform), + transition, + } + + return ( + +
  • + {children} +
  • +
    + ) +} + +const DragHandle = () => { + const { attributes, listeners, ref } = useSortableItemContext() + + return ( + + + + ) +} + +export const SortableList = Object.assign(List, { + Item, + DragHandle, +}) diff --git a/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx b/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx index 2c87dd0cd4c5..eb73da67e6ca 100644 --- a/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx +++ b/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx @@ -5,8 +5,8 @@ import { z } from "zod" import { Control } from "react-hook-form" import { AddressSchema } from "../../../lib/schemas" -import { CountrySelect } from "../../common/country-select" import { Form } from "../../common/form" +import { CountrySelect } from "../../inputs/country-select" type AddressFieldValues = z.infer diff --git a/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx index 2c4e2128895e..a12f70ff2bd6 100644 --- a/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx +++ b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx @@ -16,9 +16,9 @@ import { getOrderPaymentStatus, } from "../../../lib/order-helpers" import { TransferOwnershipSchema } from "../../../lib/schemas" -import { Combobox } from "../../common/combobox" import { Form } from "../../common/form" import { Skeleton } from "../../common/skeleton" +import { Combobox } from "../../inputs/combobox" type TransferOwnerShipFieldValues = z.infer diff --git a/packages/admin-next/dashboard/src/components/inputs/chip-input/chip-input.tsx b/packages/admin-next/dashboard/src/components/inputs/chip-input/chip-input.tsx new file mode 100644 index 000000000000..01ef93bf8a05 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/inputs/chip-input/chip-input.tsx @@ -0,0 +1,187 @@ +import { XMarkMini } from "@medusajs/icons" +import { Badge, clx } from "@medusajs/ui" +import { AnimatePresence, motion } from "framer-motion" +import { + FocusEvent, + KeyboardEvent, + forwardRef, + useImperativeHandle, + useRef, + useState, +} from "react" + +type ChipInputProps = { + value?: string[] + onChange?: (value: string[]) => void + onBlur?: () => void + name?: string + disabled?: boolean + allowDuplicates?: boolean + showRemove?: boolean + variant?: "base" | "contrast" + className?: string +} + +export const ChipInput = forwardRef( + ( + { + value, + onChange, + onBlur, + disabled, + name, + showRemove = true, + variant = "base", + allowDuplicates = false, + className, + }, + ref + ) => { + const innerRef = useRef(null) + + const isControlledRef = useRef(typeof value !== "undefined") + const isControlled = isControlledRef.current + + const [uncontrolledValue, setUncontrolledValue] = useState([]) + + useImperativeHandle( + ref, + () => innerRef.current + ) + + const [duplicateIndex, setDuplicateIndex] = useState(null) + + const chips = isControlled ? (value as string[]) : uncontrolledValue + + const handleAddChip = (chip: string) => { + const cleanValue = chip.trim() + + if (!cleanValue) { + return + } + + if (!allowDuplicates && chips.includes(cleanValue)) { + setDuplicateIndex(chips.indexOf(cleanValue)) + + setTimeout(() => { + setDuplicateIndex(null) + }, 300) + + return + } + + onChange?.([...chips, cleanValue]) + + if (!isControlled) { + setUncontrolledValue([...chips, cleanValue]) + } + } + + const handleRemoveChip = (chip: string) => { + onChange?.(chips.filter((v) => v !== chip)) + + if (!isControlled) { + setUncontrolledValue(chips.filter((v) => v !== chip)) + } + } + + const handleBlur = (e: FocusEvent) => { + onBlur?.() + + if (e.target.value) { + handleAddChip(e.target.value) + e.target.value = "" + } + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + + if (!innerRef.current?.value) { + return + } + + handleAddChip(innerRef.current?.value ?? "") + innerRef.current.value = "" + innerRef.current?.focus() + } + + if (e.key === "Backspace" && innerRef.current?.value === "") { + handleRemoveChip(chips[chips.length - 1]) + } + } + + // create a shake animation using framer motion + const shake = { + x: [0, -2, 2, -2, 2, 0], + transition: { duration: 0.3 }, + } + + return ( +
    innerRef.current?.focus()} + > + {chips.map((v, index) => { + return ( + + + + {v} + {showRemove && ( + + )} + + + + ) + })} + +
    + ) + } +) + +ChipInput.displayName = "ChipInput" diff --git a/packages/admin-next/dashboard/src/components/inputs/chip-input/index.ts b/packages/admin-next/dashboard/src/components/inputs/chip-input/index.ts new file mode 100644 index 000000000000..30cc6cbe0923 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/inputs/chip-input/index.ts @@ -0,0 +1 @@ +export * from "./chip-input" diff --git a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx b/packages/admin-next/dashboard/src/components/inputs/combobox/combobox.tsx similarity index 99% rename from packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx rename to packages/admin-next/dashboard/src/components/inputs/combobox/combobox.tsx index 5a3fe0499d94..5b50a045e4b3 100644 --- a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx +++ b/packages/admin-next/dashboard/src/components/inputs/combobox/combobox.tsx @@ -30,7 +30,7 @@ import { } from "react" import { useTranslation } from "react-i18next" -import { genericForwardRef } from "../generic-forward-ref" +import { genericForwardRef } from "../../common/generic-forward-ref" type ComboboxOption = { value: string diff --git a/packages/admin-next/dashboard/src/components/common/combobox/index.ts b/packages/admin-next/dashboard/src/components/inputs/combobox/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/components/common/combobox/index.ts rename to packages/admin-next/dashboard/src/components/inputs/combobox/index.ts diff --git a/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx b/packages/admin-next/dashboard/src/components/inputs/country-select/country-select.tsx similarity index 100% rename from packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx rename to packages/admin-next/dashboard/src/components/inputs/country-select/country-select.tsx diff --git a/packages/admin-next/dashboard/src/components/common/country-select/index.ts b/packages/admin-next/dashboard/src/components/inputs/country-select/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/components/common/country-select/index.ts rename to packages/admin-next/dashboard/src/components/inputs/country-select/index.ts diff --git a/packages/admin-next/dashboard/src/components/common/handle-input/handle-input.tsx b/packages/admin-next/dashboard/src/components/inputs/handle-input/handle-input.tsx similarity index 100% rename from packages/admin-next/dashboard/src/components/common/handle-input/handle-input.tsx rename to packages/admin-next/dashboard/src/components/inputs/handle-input/handle-input.tsx diff --git a/packages/admin-next/dashboard/src/components/common/handle-input/index.ts b/packages/admin-next/dashboard/src/components/inputs/handle-input/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/components/common/handle-input/index.ts rename to packages/admin-next/dashboard/src/components/inputs/handle-input/index.ts diff --git a/packages/admin-next/dashboard/src/components/common/percentage-input/index.ts b/packages/admin-next/dashboard/src/components/inputs/percentage-input/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/components/common/percentage-input/index.ts rename to packages/admin-next/dashboard/src/components/inputs/percentage-input/index.ts diff --git a/packages/admin-next/dashboard/src/components/common/percentage-input/percentage-input.tsx b/packages/admin-next/dashboard/src/components/inputs/percentage-input/percentage-input.tsx similarity index 100% rename from packages/admin-next/dashboard/src/components/common/percentage-input/percentage-input.tsx rename to packages/admin-next/dashboard/src/components/inputs/percentage-input/percentage-input.tsx diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx index 8d0f9d8d72be..050d10330aa7 100644 --- a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx @@ -183,6 +183,8 @@ export const DataTableRoot = ({ const to = navigateTo ? navigateTo(row) : undefined const isRowDisabled = hasSelect && !row.getCanSelect() + const isOdd = row.depth % 2 !== 0 + return ( ({ className={clx( "transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap", { + "bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover": isOdd, "cursor-pointer": !!to, "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover": row.getIsSelected(), @@ -228,6 +231,8 @@ export const DataTableRoot = ({ className={clx({ "bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']": isStickyCell, + "bg-ui-bg-subtle group-hover/row:bg-ui-bg-subtle-hover": + isOdd && isStickyCell, "left-[68px]": isStickyCell && hasSelect && !isSelectCell, "after:bg-ui-border-base": diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-category-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-category-table-columns.tsx new file mode 100644 index 000000000000..7740e3b50b47 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-category-table-columns.tsx @@ -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() + +export const useCategoryTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("name", { + header: () => , + cell: ({ getValue, row }) => { + const expandHandler = row.getToggleExpandedHandler() + + if (row.original.parent_category !== undefined) { + const path = getCategoryPath(row.original) + + return ( +
    + {path.map((chip, index) => ( +
    + + {chip.name} + + {index !== path.length - 1 && ( + + / + + )} +
    + ))} +
    + ) + } + + return ( +
    +
    + {row.getCanExpand() ? ( + { + e.stopPropagation() + e.preventDefault() + + expandHandler() + }} + size="small" + variant="transparent" + className="text-ui-fg-subtle" + > + + + ) : null} +
    + {getValue()} +
    + ) + }, + }), + columnHelper.accessor("handle", { + header: () => , + cell: ({ getValue }) => { + return + }, + }), + columnHelper.accessor("is_active", { + header: () => , + cell: ({ getValue }) => { + const { color, label } = getIsActiveProps(getValue(), t) + + return {label} + }, + }), + columnHelper.accessor("is_internal", { + header: () => , + cell: ({ getValue }) => { + const { color, label } = getIsInternalProps(getValue(), t) + + return {label} + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx index 0907848b68f9..251f367cd0d9 100644 --- a/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx @@ -30,7 +30,6 @@ export const useProductTableFilters = ( { limit: 1000, fields: "id,name", - expand: "", }, { enabled: !isSalesChannelExcluded, diff --git a/packages/admin-next/dashboard/src/hooks/use-combobox-data.tsx b/packages/admin-next/dashboard/src/hooks/use-combobox-data.tsx index afb9ce12fb6b..213a68cf5c6d 100644 --- a/packages/admin-next/dashboard/src/hooks/use-combobox-data.tsx +++ b/packages/admin-next/dashboard/src/hooks/use-combobox-data.tsx @@ -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 - offset: number -} - -type Page = { - count: number +type ComboboxExternalData = { offset: number limit: number + count: number } -type UseComboboxDataProps = { - fetcher: (params: TParams) => Promise - params?: Omit - queryKey: QueryKey +type ComboboxQueryParams = { + q?: string + offset?: number + limit?: number } -/** - * Hook for fetching infinite data for a combobox. - */ -export const useComboboxData = ({ - fetcher, - params, +export const useComboboxData = < + TResponse extends ComboboxExternalData, + TParams extends ComboboxQueryParams +>({ queryKey, -}: UseComboboxDataProps) => { - const [query, setQuery] = useState("") - const [debouncedQuery, setDebouncedQuery] = useState("") - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedUpdate = useCallback( - debounce((query) => setDebouncedQuery(query), 300), - [] - ) - - useEffect(() => { - debouncedUpdate(query) + queryFn, + getOptions, + defaultValue, + defaultValueKey, + pageSize = 10, +}: { + queryKey: QueryKey + queryFn: (params: TParams) => Promise + getOptions: (data: TResponse) => { label: string; value: string }[] + defaultValueKey?: keyof TParams + defaultValue?: string | string[] + pageSize?: number +}) => { + const { searchValue, onSearchValueChange, query } = useDebouncedSearch() - return () => debouncedUpdate.cancel() - }, [query, debouncedUpdate]) + 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) + }, + enabled: !!defaultValue, + }) - const data = useInfiniteQuery( - [...queryKey, debouncedQuery], - async ({ pageParam = 0 }) => { - const res = await fetcher({ - q: debouncedQuery, - limit: 10, + const { data, ...rest } = useInfiniteQuery({ + queryKey: [...queryKey, query], + queryFn: async ({ pageParam = 0 }) => { + return queryFn({ + q: query, + limit: pageSize, offset: pageParam, - ...params, } as TParams) - return res }, - { - getNextPageParam: (lastPage) => { - const morePages = lastPage.count > lastPage.offset + lastPage.limit - return morePages ? lastPage.offset + lastPage.limit : undefined - }, - keepPreviousData: true, - } - ) + 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, } } diff --git a/packages/admin-next/dashboard/src/hooks/use-debounced-search.tsx b/packages/admin-next/dashboard/src/hooks/use-debounced-search.tsx new file mode 100644 index 000000000000..213234b4c796 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/use-debounced-search.tsx @@ -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, + } +} diff --git a/packages/admin-next/dashboard/src/lib/client/common.ts b/packages/admin-next/dashboard/src/lib/client/common.ts index eeb124d159dd..245cccb24670 100644 --- a/packages/admin-next/dashboard/src/lib/client/common.ts +++ b/packages/admin-next/dashboard/src/lib/client/common.ts @@ -51,6 +51,7 @@ async function makeRequest< if (!response.ok) { const errorData = await response.json() + // Temp: Add a better error type throw new Error(`API error ${response.status}: ${errorData.message}`) } diff --git a/packages/admin-next/dashboard/src/routes/discounts/discount-create/components/create-discount-form/create-discount-details.tsx b/packages/admin-next/dashboard/src/routes/discounts/discount-create/components/create-discount-form/create-discount-details.tsx index fd064a7184ac..d97329f7cf1e 100644 --- a/packages/admin-next/dashboard/src/routes/discounts/discount-create/components/create-discount-form/create-discount-details.tsx +++ b/packages/admin-next/dashboard/src/routes/discounts/discount-create/components/create-discount-form/create-discount-details.tsx @@ -15,9 +15,9 @@ import { useEffect, useMemo } from "react" import { Trans, useTranslation } from "react-i18next" import { useWatch } from "react-hook-form" -import { Combobox } from "../../../../../components/common/combobox" import { Form } from "../../../../../components/common/form" -import { PercentageInput } from "../../../../../components/common/percentage-input" +import { Combobox } from "../../../../../components/inputs/combobox" +import { PercentageInput } from "../../../../../components/inputs/percentage-input" import { getCurrencySymbol } from "../../../../../lib/currencies" import { CreateDiscountFormReturn } from "./create-discount-form" import { DiscountRuleType } from "./types" diff --git a/packages/admin-next/dashboard/src/routes/discounts/discount-edit-details/components/edit-discount-form/edit-discount-details-form.tsx b/packages/admin-next/dashboard/src/routes/discounts/discount-edit-details/components/edit-discount-form/edit-discount-details-form.tsx index c0752053d224..935dd9a672ad 100644 --- a/packages/admin-next/dashboard/src/routes/discounts/discount-edit-details/components/edit-discount-form/edit-discount-details-form.tsx +++ b/packages/admin-next/dashboard/src/routes/discounts/discount-edit-details/components/edit-discount-form/edit-discount-details-form.tsx @@ -13,9 +13,9 @@ import { useForm } from "react-hook-form" import { Trans, useTranslation } from "react-i18next" import * as zod from "zod" -import { Combobox } from "../../../../../components/common/combobox" import { Form } from "../../../../../components/common/form" -import { PercentageInput } from "../../../../../components/common/percentage-input" +import { Combobox } from "../../../../../components/inputs/combobox" +import { PercentageInput } from "../../../../../components/inputs/percentage-input" import { RouteDrawer, useRouteModal, diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-customer-details.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-customer-details.tsx index 0f64b296d3d4..6d41180048a5 100644 --- a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-customer-details.tsx +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-customer-details.tsx @@ -7,8 +7,8 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { json } from "react-router-dom" -import { Combobox } from "../../../../../../components/common/combobox" import { Form } from "../../../../../../components/common/form" +import { Combobox } from "../../../../../../components/inputs/combobox" import { useCreateDraftOrder } from "../hooks" export const CreateDraftOrderCustomerDetails = () => { diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-shipping-method-details.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-shipping-method-details.tsx index 5c4caf6ce7d4..f67e2e7c49eb 100644 --- a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-shipping-method-details.tsx +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-create/components/create-draft-order-form/create-draft-order-details/create-draft-order-shipping-method-details.tsx @@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next" import { ShippingOption } from "@medusajs/medusa" import { CurrencyInput, Heading, Input } from "@medusajs/ui" import { json } from "react-router-dom" -import { Combobox } from "../../../../../../components/common/combobox" import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip" import { Form } from "../../../../../../components/common/form" +import { Combobox } from "../../../../../../components/inputs/combobox" import { getLocaleAmount } from "../../../../../../lib/money-amount-helpers" import { useCreateDraftOrder } from "../hooks" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-edit/components/variant-table/variant-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-edit/components/variant-table/variant-table.tsx index 4ab7d3ecdb11..aa151ee7ede9 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-edit/components/variant-table/variant-table.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-edit/components/variant-table/variant-table.tsx @@ -1,18 +1,18 @@ import { PricedVariant } from "@medusajs/client-types" -import { useTranslation } from "react-i18next" +import { Order } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" -import { useState } from "react" import { useAdminVariants } from "medusa-react" -import { Button } from "@medusajs/ui" -import { Order } from "@medusajs/medusa" +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { SplitView } from "../../../../../components/layout/split-view" import { DataTable } from "../../../../../components/table/data-table" import { useDataTable } from "../../../../../hooks/use-data-table.tsx" -import { SplitView } from "../../../../../components/layout/split-view" -import { useVariantTableQuery } from "./use-variant-table-query" import { useVariantTableColumns } from "./use-variant-table-columns" import { useVariantTableFilters } from "./use-variant-table-filters" +import { useVariantTableQuery } from "./use-variant-table-query" const PAGE_SIZE = 50 diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx index 90fad0227a97..03869f2b78f7 100644 --- a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx @@ -1,25 +1,17 @@ -import { PencilSquare, TriangleRightMini } from "@medusajs/icons" +import { PencilSquare } from "@medusajs/icons" import { AdminProductCategoryResponse } from "@medusajs/types" -import { Container, Heading, IconButton, Text, clx } from "@medusajs/ui" +import { Container, Heading } from "@medusajs/ui" import { keepPreviousData } from "@tanstack/react-query" import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" import { useTranslation } from "react-i18next" + import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" -import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell" -import { - TextCell, - TextHeader, -} from "../../../../../components/table/table-cells/common/text-cell" import { useCategories } from "../../../../../hooks/api/categories" +import { useCategoryTableColumns } from "../../../../../hooks/table/columns/use-category-table-columns" import { useDataTable } from "../../../../../hooks/use-data-table" import { useCategoryTableQuery } from "../../../common/hooks/use-category-table-query" -import { - getCategoryPath, - getIsActiveProps, - getIsInternalProps, -} from "../../../common/utils" const PAGE_SIZE = 20 @@ -51,7 +43,7 @@ export const CategoryListTable = () => { } ) - const columns = useCategoryTableColumns() + const columns = useColumns() const { table } = useDataTable({ data: product_categories || [], @@ -114,83 +106,12 @@ const CategoryRowActions = ({ const columnHelper = createColumnHelper() -const useCategoryTableColumns = () => { - const { t } = useTranslation() +const useColumns = () => { + const base = useCategoryTableColumns() return useMemo( () => [ - columnHelper.accessor("name", { - header: () => , - cell: ({ getValue, row }) => { - const expandHandler = row.getToggleExpandedHandler() - - console.log(row.original) - - if (row.original.parent_category !== undefined) { - const path = getCategoryPath(row.original) - - return ( -
    - {path.map((chip) => ( -
    - {chip.name} -
    - ))} -
    - ) - } - - return ( -
    -
    - {row.getCanExpand() ? ( - { - e.stopPropagation() - e.preventDefault() - - expandHandler() - }} - size="small" - variant="transparent" - > - - - ) : null} -
    - {getValue()} -
    - ) - }, - }), - columnHelper.accessor("handle", { - header: () => , - cell: ({ getValue }) => { - return - }, - }), - columnHelper.accessor("is_active", { - header: () => , - cell: ({ getValue }) => { - const { color, label } = getIsActiveProps(getValue(), t) - - return {label} - }, - }), - columnHelper.accessor("is_internal", { - header: () => , - cell: ({ getValue }) => { - const { color, label } = getIsInternalProps(getValue(), t) - - return {label} - }, - }), + ...base, columnHelper.display({ id: "actions", cell: ({ row }) => { @@ -198,6 +119,6 @@ const useCategoryTableColumns = () => { }, }), ], - [t] + [base] ) } diff --git a/packages/admin-next/dashboard/src/v2-routes/collections/collection-create/components/create-collection-form/create-collection-form.tsx b/packages/admin-next/dashboard/src/v2-routes/collections/collection-create/components/create-collection-form/create-collection-form.tsx index 83d9b126a109..4b4a057b4f41 100644 --- a/packages/admin-next/dashboard/src/v2-routes/collections/collection-create/components/create-collection-form/create-collection-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/collections/collection-create/components/create-collection-form/create-collection-form.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next" import * as zod from "zod" import { Form } from "../../../../../components/common/form" -import { HandleInput } from "../../../../../components/common/handle-input" +import { HandleInput } from "../../../../../components/inputs/handle-input" import { RouteFocusModal, useRouteModal, diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx index 9083b0235f8b..9e2324d15a16 100644 --- a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx @@ -6,14 +6,14 @@ import { useRouteModal, } from "../../../../../../components/route-modal" -import { CountrySelect } from "../../../../../../components/common/country-select" -import { Form } from "../../../../../../components/common/form" +import { zodResolver } from "@hookform/resolvers/zod" import { InventoryNext } from "@medusajs/types" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" -import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory" import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" +import { Form } from "../../../../../../components/common/form" +import { CountrySelect } from "../../../../../../components/inputs/country-select" +import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory" type EditInventoryItemAttributeFormProps = { item: InventoryNext.InventoryItemDTO diff --git a/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/category-combobox.tsx b/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/category-combobox.tsx new file mode 100644 index 000000000000..2eca3930b07b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/category-combobox.tsx @@ -0,0 +1,380 @@ +import { + ArrowUturnLeft, + CheckMini, + TriangleRightMini, + TrianglesMini, + XMarkMini, +} from "@medusajs/icons" +import { AdminProductCategoryResponse } from "@medusajs/types" +import { Text, clx } from "@medusajs/ui" +import * as Popover from "@radix-ui/react-popover" +import { + ComponentPropsWithoutRef, + Fragment, + MouseEvent, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react" +import { Trans, useTranslation } from "react-i18next" +import { Divider } from "../../../../../components/common/divider" +import { TextSkeleton } from "../../../../../components/common/skeleton" +import { useCategories } from "../../../../../hooks/api/categories" +import { useDebouncedSearch } from "../../../../../hooks/use-debounced-search" + +interface CategoryComboboxProps + extends Omit< + ComponentPropsWithoutRef<"input">, + "value" | "defaultValue" | "onChange" + > { + value: string[] + onChange: (value: string[]) => void +} + +type Level = { + id: string + label: string +} + +export const CategoryCombobox = forwardRef< + HTMLInputElement, + CategoryComboboxProps +>(({ value, onChange, className, ...props }, ref) => { + const innerRef = useRef(null) + + useImperativeHandle( + ref, + () => innerRef.current, + [] + ) + + const [open, setOpen] = useState(false) + + const { i18n, t } = useTranslation() + + const [level, setLevel] = useState([]) + const { searchValue, onSearchValueChange, query } = useDebouncedSearch() + + const { product_categories, isPending, isError, error } = useCategories( + { + q: query, + parent_category_id: !searchValue ? getParentId(level) : undefined, + include_descendants_tree: !searchValue ? true : false, + }, + { + enabled: open, + } + ) + + const [showLoading, setShowLoading] = useState(false) + + /** + * We add a small artificial delay to the end of the loading state, + * this is done to prevent the popover from flickering too much when + * navigating between levels or searching. + */ + useEffect(() => { + let timeoutId: ReturnType | undefined + + if (isPending) { + setShowLoading(true) + } else { + timeoutId = setTimeout(() => { + setShowLoading(false) + }, 150) + } + + return () => { + clearTimeout(timeoutId) + } + }, [isPending]) + + useEffect(() => { + if (searchValue) { + setLevel([]) + } + }, [searchValue]) + + function handleLevelUp(e: MouseEvent) { + e.preventDefault() + e.stopPropagation() + + setLevel(level.slice(0, level.length - 1)) + + innerRef.current?.focus() + } + + function handleLevelDown(option: ProductCategoryOption) { + return (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + setLevel([...level, { id: option.value, label: option.label }]) + + innerRef.current?.focus() + } + } + + function handleSelect(option: ProductCategoryOption) { + return (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (isSelected(value, option.value)) { + onChange(value.filter((v) => v !== option.value)) + } else { + onChange([...value, option.value]) + } + + innerRef.current?.focus() + } + } + + function handleOpenChange(open: boolean) { + if (!open) { + onSearchValueChange("") + setLevel([]) + } + + if (open) { + requestAnimationFrame(() => { + innerRef.current?.focus() + }) + } + + setOpen(open) + } + + const options = getOptions(product_categories || []) + + const showTag = value.length > 0 && !open + + if (isError) { + throw error + } + + return ( + + +
    + {open ? ( + onSearchValueChange(e.target.value)} + className={clx( + "txt-compact-small w-full appearance-none bg-transparent outline-none", + "placeholder:text-ui-fg-muted" + )} + {...props} + /> + ) : showTag ? ( +
    +
    +
    + {value.length} + +
    +
    + + {t("general.selected")} + +
    + ) : ( +
    + )} +
    + +
    +
    +
    + + { + e.preventDefault() + }} + > + {!searchValue && level.length > 0 && ( + +
    + +
    + +
    + )} +
    + {options.length > 0 && + !showLoading && + options.map((option) => ( +
    + + {option.has_children && !searchValue && ( + + )} +
    + ))} + {showLoading && + Array.from({ length: 5 }).map((_, index) => ( +
    +
    + +
    +
    + ))} + {options.length === 0 && !showLoading && ( +
    + + {query ? ( + , + ]} + /> + ) : ( + t("general.noRecordsFound") + )} + +
    + )} +
    + + + + ) +}) + +CategoryCombobox.displayName = "CategoryCombobox" + +type ProductCategoryOption = { + value: string + label: string + has_children: boolean +} + +function getParentId(level: Level[]): string { + if (!level.length) { + return "null" + } + + return level[level.length - 1].id +} + +function getParentLabel(level: Level[]): string | null { + if (!level.length) { + return null + } + + return level[level.length - 1].label +} + +function getOptions( + categories: AdminProductCategoryResponse["product_category"][] +): ProductCategoryOption[] { + return categories.map((cat) => { + return { + value: cat.id, + label: cat.name, + has_children: cat.category_children?.length > 0, + } + }) +} + +function isSelected(values: string[], value: string): boolean { + return values.includes(value) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/index.ts new file mode 100644 index 000000000000..d858c7ce3745 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/common/components/category-combobox/index.ts @@ -0,0 +1 @@ +export * from "./category-combobox" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/common/variant-pricing-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/common/variant-pricing-form.tsx index ba17a9d001c1..13ae7b25e312 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/common/variant-pricing-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/common/variant-pricing-form.tsx @@ -1,18 +1,18 @@ -import { UseFormReturn, useWatch } from "react-hook-form" -import { CreateProductSchemaType } from "../product-create/schema" -import { DataGrid } from "../../../components/grid/data-grid" -import { useCurrencies } from "../../../hooks/api/currencies" -import { useStore } from "../../../hooks/api/store" -import { ColumnDef, createColumnHelper } from "@tanstack/react-table" import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types" -import { useTranslation } from "react-i18next" +import { ColumnDef, createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" -import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell" +import { UseFormReturn, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { DataGrid } from "../../../components/grid/data-grid" import { CurrencyCell } from "../../../components/grid/grid-cells/common/currency-cell" +import { ReadonlyCell } from "../../../components/grid/grid-cells/common/readonly-cell" import { DataGridMeta } from "../../../components/grid/types" +import { useCurrencies } from "../../../hooks/api/currencies" +import { useStore } from "../../../hooks/api/store" +import { ProductCreateSchemaType } from "../product-create/schema" type VariantPricingFormProps = { - form: UseFormReturn + form: UseFormReturn } export const VariantPricingForm = ({ form }: VariantPricingFormProps) => { diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx index 994ca18874b2..be90d31e2d24 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-attributes/components/product-attributes-form/product-attributes-form.tsx @@ -4,8 +4,8 @@ import { Button, Input } from "@medusajs/ui" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import * as zod from "zod" -import { CountrySelect } from "../../../../../components/common/country-select" import { Form } from "../../../../../components/common/form" +import { CountrySelect } from "../../../../../components/inputs/country-select" import { RouteDrawer, useRouteModal, diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx index c2d028da07c5..837ed1e1e73e 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx @@ -1,10 +1,12 @@ import { zodResolver } from "@hookform/resolvers/zod" import { Product } from "@medusajs/medusa" -import { Button, Input } from "@medusajs/ui" +import { Button, Input, 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 { ChipInput } from "../../../../../components/inputs/chip-input" import { RouteDrawer, useRouteModal, @@ -34,13 +36,25 @@ export const CreateProductOptionForm = ({ resolver: zodResolver(CreateProductOptionSchema), }) - const { mutateAsync, isLoading } = useCreateProductOption(product.id) + const { mutateAsync, isPending } = useCreateProductOption(product.id) const handleSubmit = form.handleSubmit(async (values) => { mutateAsync(values, { - onSuccess: () => { + onSuccess: ({ option }) => { + toast.success(t("general.success"), { + description: t("products.options.create.successToast", { + title: option.title, + }), + dismissLabel: t("general.close"), + }) handleSuccess() }, + onError: async (err) => { + toast.error(t("general.error"), { + description: err.message, + dismissLabel: t("general.close"), + }) + }, }) }) @@ -71,21 +85,14 @@ export const CreateProductOptionForm = ({ { + render={({ field }) => { return ( {t("products.fields.options.variations")} - { - const val = e.target.value - onChange(val.split(",").map((v) => v.trim())) - }} - /> + @@ -100,7 +107,7 @@ export const CreateProductOptionForm = ({ {t("actions.cancel")} -
    diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx index e1d4fef92ba4..66ed721cc4ee 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx @@ -1,22 +1,22 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Product, ProductVariant } from "@medusajs/medusa" +import { Product } from "@medusajs/medusa" import { Button, Heading, Input, Switch } from "@medusajs/ui" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" import { Fragment } from "react" -import { Combobox } from "../../../../../components/common/combobox" -import { CountrySelect } from "../../../../../components/common/country-select" import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" +import { Combobox } from "../../../../../components/inputs/combobox" +import { CountrySelect } from "../../../../../components/inputs/country-select" import { RouteDrawer, useRouteModal, } from "../../../../../components/route-modal" +import { useCreateProductVariant } from "../../../../../hooks/api/products" import { castNumber } from "../../../../../lib/cast-number" import { optionalInt } from "../../../../../lib/validation" -import { useCreateProductVariant } from "../../../../../hooks/api/products" type CreateProductVariantFormProps = { product: Product diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-attributes-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-attributes-form.tsx deleted file mode 100644 index 4ab238e4b290..000000000000 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-create/components/product-attributes-form.tsx +++ /dev/null @@ -1,801 +0,0 @@ -import { - Button, - Checkbox, - Heading, - Input, - Select, - Switch, - Text, - Textarea, -} from "@medusajs/ui" -import { Trans, useTranslation } from "react-i18next" -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels" - -import { SalesChannel } from "@medusajs/medusa" -import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" -import { Fragment, useMemo, useState } from "react" -import { CountrySelect } from "../../../../components/common/country-select" -import { Form } from "../../../../components/common/form" -import { HandleInput } from "../../../../components/common/handle-input" -import { DataTable } from "../../../../components/table/data-table" -import { useSalesChannelTableColumns } from "../../../../hooks/table/columns/use-sales-channel-table-columns" -import { useSalesChannelTableFilters } from "../../../../hooks/table/filters/use-sales-channel-table-filters" -import { useSalesChannelTableQuery } from "../../../../hooks/table/query/use-sales-channel-table-query" -import { useDataTable } from "../../../../hooks/use-data-table" -import { Combobox } from "../../../../components/common/combobox" -import { FileUpload } from "../../../../components/common/file-upload" -import { List } from "../../../../components/common/list" -import { useProductTypes } from "../../../../hooks/api/product-types" -import { useCollections } from "../../../../hooks/api/collections" -import { useSalesChannels } from "../../../../hooks/api/sales-channels" -import { useCategories } from "../../../../hooks/api/categories" -import { useTags } from "../../../../hooks/api/tags" -import { Keypair } from "../../../../components/common/keypair" -import { UseFormReturn } from "react-hook-form" -import { CreateProductSchemaType } from "../schema" - -type ProductAttributesProps = { - form: UseFormReturn -} - -const SUPPORTED_FORMATS = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/heic", - "image/svg+xml", -] - -const permutations = ( - data: { title: string; values: string[] }[] -): { [key: string]: string }[] => { - if (data.length === 0) { - return [] - } - - if (data.length === 1) { - return data[0].values.map((value) => ({ [data[0].title]: value })) - } - - const toProcess = data[0] - const rest = data.slice(1) - - return toProcess.values.flatMap((value) => { - return permutations(rest).map((permutation) => { - return { - [toProcess.title]: value, - ...permutation, - } - }) - }) -} - -const generateNameFromPermutation = (permutation: { - [key: string]: string -}) => { - return Object.values(permutation).join(" / ") -} - -export const ProductAttributesForm = ({ form }: ProductAttributesProps) => { - const { t } = useTranslation() - const [open, onOpenChange] = useState(false) - const { product_types, isLoading: isLoadingTypes } = useProductTypes() - const { product_tags, isLoading: isLoadingTags } = useTags() - const { collections, isLoading: isLoadingCollections } = useCollections() - const { sales_channels, isLoading: isLoadingSalesChannels } = - useSalesChannels() - const { product_categories, isLoading: isLoadingCategories } = useCategories() - - const options = form.watch("options") - const optionPermutations = permutations(options ?? []) - - // const { append } = useFieldArray({ - // name: "images", - // control: form.control, - // // keyName: "field_id", - // }) - - return ( - - -
    -
    -
    - {t("products.createProductTitle")} - - {t("products.createProductHint")} - -
    -
    -
    -
    -
    - { - return ( - - - {t("products.fields.title.label")} - - - - - - ) - }} - /> - { - return ( - - - {t("products.fields.subtitle.label")} - - - - - - ) - }} - /> -
    - - ]} - /> - -
    -
    - { - return ( - - - {t("fields.handle")} - - - - - - ) - }} - /> -
    - { - return ( - - - {t("products.fields.description.label")} - - -