Skip to content

Commit f3d5c5d

Browse files
committed
link and unlink option to product
1 parent 6fae878 commit f3d5c5d

File tree

13 files changed

+311
-12
lines changed

13 files changed

+311
-12
lines changed

packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ export function getRouteMap({
146146
lazy: () =>
147147
import("../../routes/products/product-metadata"),
148148
},
149+
{
150+
path: "options/manage",
151+
lazy: () =>
152+
import(
153+
"../../routes/products/product-options-manage"
154+
),
155+
},
149156
],
150157
},
151158
{

packages/admin/dashboard/src/hooks/api/products.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,29 @@ export const useDeleteProduct = (
312312
})
313313
}
314314

315+
export const useLinkProductOptions = (
316+
productId: string,
317+
options?: UseMutationOptions<
318+
HttpTypes.AdminProductResponse,
319+
FetchError,
320+
HttpTypes.AdminLinkProductOptions
321+
>
322+
) => {
323+
return useMutation({
324+
mutationFn: (payload) => sdk.admin.product.linkOptions(productId, payload),
325+
onSuccess: (data, variables, context) => {
326+
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
327+
queryClient.invalidateQueries({
328+
queryKey: productsQueryKeys.detail(productId),
329+
})
330+
queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() })
331+
332+
options?.onSuccess?.(data, variables, context)
333+
},
334+
...options,
335+
})
336+
}
337+
315338
export const useExportProducts = (
316339
query?: HttpTypes.AdminProductListParams,
317340
options?: UseMutationOptions<

packages/admin/dashboard/src/i18n/translations/$schema.json

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@
512512
"cannotUndo": {
513513
"type": "string"
514514
},
515-
"organize": {
515+
"manage": {
516516
"type": "string"
517517
}
518518
},
@@ -559,7 +559,7 @@
559559
"export",
560560
"import",
561561
"cannotUndo",
562-
"organize"
562+
"manage"
563563
],
564564
"additionalProperties": false
565565
},
@@ -2587,11 +2587,39 @@
25872587
"required": ["header", "successToast"],
25882588
"additionalProperties": false
25892589
},
2590+
"manage": {
2591+
"type": "object",
2592+
"properties": {
2593+
"header": {
2594+
"type": "string"
2595+
},
2596+
"description": {
2597+
"type": "string"
2598+
},
2599+
"label": {
2600+
"type": "string"
2601+
},
2602+
"hint": {
2603+
"type": "string"
2604+
},
2605+
"placeholder": {
2606+
"type": "string"
2607+
}
2608+
},
2609+
"required": [
2610+
"header",
2611+
"description",
2612+
"label",
2613+
"hint",
2614+
"placeholder"
2615+
],
2616+
"additionalProperties": false
2617+
},
25902618
"deleteWarning": {
25912619
"type": "string"
25922620
}
25932621
},
2594-
"required": ["header", "edit", "create", "deleteWarning"],
2622+
"required": ["header", "edit", "create", "manage", "deleteWarning"],
25952623
"additionalProperties": false
25962624
},
25972625
"organization": {

packages/admin/dashboard/src/i18n/translations/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@
142142
"export": "Export",
143143
"import": "Import",
144144
"cannotUndo": "This action cannot be undone",
145-
"organize": "Organize"
145+
"manage": "Manage"
146146
},
147147
"operators": {
148148
"in": "In"
@@ -688,6 +688,13 @@
688688
"header": "Create Option",
689689
"successToast": "Option {{title}} was successfully created."
690690
},
691+
"manage": {
692+
"header": "Manage Product Options",
693+
"description": "Associate or disassociate product options from this product.",
694+
"label": "Product Options",
695+
"hint": "Select which options should be associated to this product.",
696+
"placeholder": "Select options"
697+
},
691698
"deleteWarning": "You are about to delete the product option: {{title}}. This action cannot be undone."
692699
},
693700
"organization": {

packages/admin/dashboard/src/routes/products/product-detail/components/product-option-section/product-option-section.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PencilSquare, Plus } from "@medusajs/icons"
1+
import { PencilSquare } from "@medusajs/icons"
22
import { Badge, Container, Heading } from "@medusajs/ui"
33
import { useTranslation } from "react-i18next"
44
import { ActionMenu } from "../../../../../components/common/action-menu"
@@ -47,9 +47,9 @@ export const ProductOptionSection = ({
4747
{
4848
actions: [
4949
{
50-
label: t("actions.create"),
51-
to: "options/create",
52-
icon: <Plus />,
50+
label: t("actions.manage"),
51+
to: "options/manage",
52+
icon: <PencilSquare />,
5353
},
5454
],
5555
},
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ProductOptionsManageForm } from "./product-options-manage-form"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { HttpTypes } from "@medusajs/types"
2+
import { Button, toast } from "@medusajs/ui"
3+
import { useMemo, useState } from "react"
4+
import { useTranslation } from "react-i18next"
5+
import * as zod from "zod"
6+
7+
import { Form } from "../../../../../components/common/form"
8+
import { Combobox } from "../../../../../components/inputs/combobox"
9+
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
10+
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
11+
import { useExtendableForm } from "../../../../../dashboard-app"
12+
import {
13+
useLinkProductOptions,
14+
useProductOptions,
15+
} from "../../../../../hooks/api"
16+
import { useExtension } from "../../../../../providers/extension-provider"
17+
18+
type ProductOptionsManageFormProps = {
19+
product: HttpTypes.AdminProduct
20+
}
21+
22+
const ProductOptionsManageSchema = zod.object({
23+
option_ids: zod.array(zod.string()),
24+
})
25+
26+
export const ProductOptionsManageForm = ({
27+
product,
28+
}: ProductOptionsManageFormProps) => {
29+
const { t } = useTranslation()
30+
const { handleSuccess } = useRouteModal()
31+
const { getFormConfigs } = useExtension()
32+
const configs = getFormConfigs("product", "edit")
33+
34+
const { product_options = [], isLoading } = useProductOptions({
35+
is_exclusive: false,
36+
})
37+
38+
const productOptionChoices = useMemo(() => {
39+
return product_options.map((option) => ({
40+
value: option.id,
41+
label: option.title,
42+
}))
43+
}, [product_options])
44+
45+
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>(
46+
product.options?.map((opt) => opt.id) || []
47+
)
48+
49+
const form = useExtendableForm({
50+
defaultValues: {
51+
option_ids: product.options?.map((opt) => opt.id) || [],
52+
},
53+
schema: ProductOptionsManageSchema,
54+
configs: configs,
55+
data: product,
56+
})
57+
58+
const { mutateAsync, isPending } = useLinkProductOptions(product.id)
59+
60+
const handleProductOptionSelect = (optionIds: string[]) => {
61+
setSelectedOptionIds(optionIds)
62+
form.setValue("option_ids", optionIds)
63+
}
64+
65+
const handleSubmit = form.handleSubmit(async (data) => {
66+
const currentOptionIds = product.options?.map((opt) => opt.id) || []
67+
const newOptionIds = data.option_ids
68+
69+
// Determine which options to add and remove
70+
const optionsToAdd = newOptionIds.filter(
71+
(id) => !currentOptionIds.includes(id)
72+
)
73+
const optionsToRemove = currentOptionIds.filter(
74+
(id) => !newOptionIds.includes(id)
75+
)
76+
77+
await mutateAsync(
78+
{
79+
add: optionsToAdd,
80+
remove: optionsToRemove,
81+
},
82+
{
83+
onSuccess: ({ product }) => {
84+
toast.success(
85+
t("products.organization.edit.toasts.success", {
86+
title: product.title,
87+
})
88+
)
89+
handleSuccess()
90+
},
91+
onError: (error) => {
92+
toast.error(error.message)
93+
},
94+
}
95+
)
96+
})
97+
98+
return (
99+
<RouteDrawer.Form form={form}>
100+
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col">
101+
<RouteDrawer.Body>
102+
<div className="flex h-full flex-col gap-y-4">
103+
<Form.Field
104+
control={form.control}
105+
name="option_ids"
106+
render={({ field }) => {
107+
return (
108+
<Form.Item>
109+
<Form.Label>
110+
{t("products.options.manage.label")}
111+
</Form.Label>
112+
<Form.Hint>{t("products.options.manage.hint")}</Form.Hint>
113+
<Form.Control>
114+
<Combobox
115+
{...field}
116+
value={selectedOptionIds}
117+
onChange={(value) =>
118+
handleProductOptionSelect(value as string[])
119+
}
120+
options={productOptionChoices}
121+
placeholder={t("products.options.manage.placeholder")}
122+
disabled={isLoading}
123+
/>
124+
</Form.Control>
125+
<Form.ErrorMessage />
126+
</Form.Item>
127+
)
128+
}}
129+
/>
130+
</div>
131+
</RouteDrawer.Body>
132+
<RouteDrawer.Footer>
133+
<div className="flex items-center justify-end gap-x-2">
134+
<RouteDrawer.Close asChild>
135+
<Button size="small" variant="secondary">
136+
{t("actions.cancel")}
137+
</Button>
138+
</RouteDrawer.Close>
139+
<Button size="small" type="submit" isLoading={isPending}>
140+
{t("actions.save")}
141+
</Button>
142+
</div>
143+
</RouteDrawer.Footer>
144+
</KeyboundForm>
145+
</RouteDrawer.Form>
146+
)
147+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ProductOptionsManage as Component } from "./product-options-manage"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Heading } from "@medusajs/ui"
2+
import { useTranslation } from "react-i18next"
3+
import { useParams } from "react-router-dom"
4+
5+
import { RouteDrawer } from "../../../components/modals"
6+
import { useProduct } from "../../../hooks/api"
7+
import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants"
8+
import { ProductOptionsManageForm } from "./components/product-options-manage-form"
9+
10+
export const ProductOptionsManage = () => {
11+
const { id } = useParams()
12+
const { t } = useTranslation()
13+
14+
const { product, isLoading, isError, error } = useProduct(id!, {
15+
fields: PRODUCT_DETAIL_FIELDS,
16+
})
17+
18+
if (isError) {
19+
throw error
20+
}
21+
22+
return (
23+
<RouteDrawer>
24+
<RouteDrawer.Header>
25+
<RouteDrawer.Title asChild>
26+
<Heading>{t("products.options.manage.header")}</Heading>
27+
</RouteDrawer.Title>
28+
<RouteDrawer.Description className="sr-only">
29+
{t("products.options.manage.description")}
30+
</RouteDrawer.Description>
31+
</RouteDrawer.Header>
32+
{!isLoading && product && <ProductOptionsManageForm product={product} />}
33+
</RouteDrawer>
34+
)
35+
}

packages/core/js-sdk/src/admin/product.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,4 +1075,42 @@ export class Product {
10751075
}
10761076
)
10771077
}
1078+
1079+
/**
1080+
* This method links product options to a product. It allows adding new options
1081+
* or removing existing ones. It sends a request to the
1082+
* [Link Product Options](TODO)
1083+
* API route.
1084+
*
1085+
* @param productId - The product's ID.
1086+
* @param body - The options to add or remove.
1087+
* @param query - Configure the fields to retrieve in the product.
1088+
* @param headers - Headers to pass in the request
1089+
* @returns The product's details.
1090+
*
1091+
* @example
1092+
* sdk.admin.product.linkOptions("prod_123", {
1093+
* add: ["prodopt_123", "prodopt_456"],
1094+
* remove: ["prodopt_789"]
1095+
* })
1096+
* .then(({ product }) => {
1097+
* console.log(product)
1098+
* })
1099+
*/
1100+
async linkOptions(
1101+
productId: string,
1102+
body: HttpTypes.AdminLinkProductOptions,
1103+
query?: SelectParams,
1104+
headers?: ClientHeaders
1105+
) {
1106+
return await this.client.fetch<HttpTypes.AdminProductResponse>(
1107+
`/admin/products/${productId}/options`,
1108+
{
1109+
method: "POST",
1110+
headers,
1111+
body: body,
1112+
query,
1113+
}
1114+
)
1115+
}
10781116
}

0 commit comments

Comments
 (0)