Ver código fonte

feat(core): Allow variant options to be added & removed

Michael Bromley 2 anos atrás
pai
commit
8cb9b271dd

+ 8 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -2661,7 +2661,12 @@ export type Mutation = {
     removeFacetsFromChannel: Array<RemoveFacetFromChannelResult>;
     /** Remove members from a Zone */
     removeMembersFromZone: Zone;
-    /** Remove an OptionGroup from a Product */
+    /**
+     * Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+     * the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+     * Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+     * as well as removing any of the group's options from the Product's ProductVariants.
+     */
     removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Removes ProductVariants from the specified Channel */
     removeProductVariantsFromChannel: Array<ProductVariant>;
@@ -3178,6 +3183,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 };
 
 export type MutationRemoveOptionGroupFromProductArgs = {
+    force?: InputMaybe<Scalars['Boolean']>;
     optionGroupId: Scalars['ID'];
     productId: Scalars['ID'];
 };
@@ -5823,6 +5829,7 @@ export type UpdateProductVariantInput = {
     facetValueIds?: InputMaybe<Array<Scalars['ID']>>;
     featuredAssetId?: InputMaybe<Scalars['ID']>;
     id: Scalars['ID'];
+    optionIds?: InputMaybe<Array<Scalars['ID']>>;
     outOfStockThreshold?: InputMaybe<Scalars['Int']>;
     price?: InputMaybe<Scalars['Money']>;
     sku?: InputMaybe<Scalars['String']>;

+ 8 - 1
packages/common/src/generated-types.ts

@@ -2705,7 +2705,12 @@ export type Mutation = {
   removeFacetsFromChannel: Array<RemoveFacetFromChannelResult>;
   /** Remove members from a Zone */
   removeMembersFromZone: Zone;
-  /** Remove an OptionGroup from a Product */
+  /**
+   * Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+   * the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+   * Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+   * as well as removing any of the group's options from the Product's ProductVariants.
+   */
   removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
   /** Removes ProductVariants from the specified Channel */
   removeProductVariantsFromChannel: Array<ProductVariant>;
@@ -3326,6 +3331,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 
 
 export type MutationRemoveOptionGroupFromProductArgs = {
+  force?: InputMaybe<Scalars['Boolean']>;
   optionGroupId: Scalars['ID'];
   productId: Scalars['ID'];
 };
@@ -6139,6 +6145,7 @@ export type UpdateProductVariantInput = {
   facetValueIds?: InputMaybe<Array<Scalars['ID']>>;
   featuredAssetId?: InputMaybe<Scalars['ID']>;
   id: Scalars['ID'];
+  optionIds?: InputMaybe<Array<Scalars['ID']>>;
   outOfStockThreshold?: InputMaybe<Scalars['Int']>;
   price?: InputMaybe<Scalars['Money']>;
   sku?: InputMaybe<Scalars['String']>;

+ 1 - 0
packages/core/e2e/graphql/fragments.ts

@@ -59,6 +59,7 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
             id
             code
             languageCode
+            groupId
             name
         }
         facetValues {

+ 109 - 13
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -2661,7 +2661,12 @@ export type Mutation = {
     removeFacetsFromChannel: Array<RemoveFacetFromChannelResult>;
     /** Remove members from a Zone */
     removeMembersFromZone: Zone;
-    /** Remove an OptionGroup from a Product */
+    /**
+     * Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+     * the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+     * Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+     * as well as removing any of the group's options from the Product's ProductVariants.
+     */
     removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Removes ProductVariants from the specified Channel */
     removeProductVariantsFromChannel: Array<ProductVariant>;
@@ -3178,6 +3183,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 };
 
 export type MutationRemoveOptionGroupFromProductArgs = {
+    force?: InputMaybe<Scalars['Boolean']>;
     optionGroupId: Scalars['ID'];
     productId: Scalars['ID'];
 };
@@ -5823,6 +5829,7 @@ export type UpdateProductVariantInput = {
     facetValueIds?: InputMaybe<Array<Scalars['ID']>>;
     featuredAssetId?: InputMaybe<Scalars['ID']>;
     id: Scalars['ID'];
+    optionIds?: InputMaybe<Array<Scalars['ID']>>;
     outOfStockThreshold?: InputMaybe<Scalars['Int']>;
     price?: InputMaybe<Scalars['Money']>;
     sku?: InputMaybe<Scalars['String']>;
@@ -7742,7 +7749,7 @@ export type ProductVariantFragment = {
     sku: string;
     taxRateApplied: { id: string; name: string; value: number };
     taxCategory: { id: string; name: string };
-    options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+    options: Array<{ id: string; code: string; languageCode: LanguageCode; groupId: string; name: string }>;
     facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
     featuredAsset?: {
         id: string;
@@ -7808,7 +7815,13 @@ export type ProductWithVariantsFragment = {
         sku: string;
         taxRateApplied: { id: string; name: string; value: number };
         taxCategory: { id: string; name: string };
-        options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+        options: Array<{
+            id: string;
+            code: string;
+            languageCode: LanguageCode;
+            groupId: string;
+            name: string;
+        }>;
         facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
         featuredAsset?: {
             id: string;
@@ -8269,7 +8282,13 @@ export type UpdateProductMutation = {
             sku: string;
             taxRateApplied: { id: string; name: string; value: number };
             taxCategory: { id: string; name: string };
-            options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+            options: Array<{
+                id: string;
+                code: string;
+                languageCode: LanguageCode;
+                groupId: string;
+                name: string;
+            }>;
             facetValues: Array<{
                 id: string;
                 code: string;
@@ -8349,7 +8368,13 @@ export type CreateProductMutation = {
             sku: string;
             taxRateApplied: { id: string; name: string; value: number };
             taxCategory: { id: string; name: string };
-            options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+            options: Array<{
+                id: string;
+                code: string;
+                languageCode: LanguageCode;
+                groupId: string;
+                name: string;
+            }>;
             facetValues: Array<{
                 id: string;
                 code: string;
@@ -8430,7 +8455,13 @@ export type GetProductWithVariantsQuery = {
             sku: string;
             taxRateApplied: { id: string; name: string; value: number };
             taxCategory: { id: string; name: string };
-            options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+            options: Array<{
+                id: string;
+                code: string;
+                languageCode: LanguageCode;
+                groupId: string;
+                name: string;
+            }>;
             facetValues: Array<{
                 id: string;
                 code: string;
@@ -8500,7 +8531,13 @@ export type CreateProductVariantsMutation = {
         sku: string;
         taxRateApplied: { id: string; name: string; value: number };
         taxCategory: { id: string; name: string };
-        options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+        options: Array<{
+            id: string;
+            code: string;
+            languageCode: LanguageCode;
+            groupId: string;
+            name: string;
+        }>;
         facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
         featuredAsset?: {
             id: string;
@@ -8545,7 +8582,13 @@ export type UpdateProductVariantsMutation = {
         sku: string;
         taxRateApplied: { id: string; name: string; value: number };
         taxCategory: { id: string; name: string };
-        options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+        options: Array<{
+            id: string;
+            code: string;
+            languageCode: LanguageCode;
+            groupId: string;
+            name: string;
+        }>;
         facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
         featuredAsset?: {
             id: string;
@@ -9056,7 +9099,13 @@ export type AssignProductsToChannelMutation = {
             sku: string;
             taxRateApplied: { id: string; name: string; value: number };
             taxCategory: { id: string; name: string };
-            options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+            options: Array<{
+                id: string;
+                code: string;
+                languageCode: LanguageCode;
+                groupId: string;
+                name: string;
+            }>;
             facetValues: Array<{
                 id: string;
                 code: string;
@@ -9136,7 +9185,13 @@ export type RemoveProductsFromChannelMutation = {
             sku: string;
             taxRateApplied: { id: string; name: string; value: number };
             taxCategory: { id: string; name: string };
-            options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+            options: Array<{
+                id: string;
+                code: string;
+                languageCode: LanguageCode;
+                groupId: string;
+                name: string;
+            }>;
             facetValues: Array<{
                 id: string;
                 code: string;
@@ -9189,7 +9244,13 @@ export type AssignProductVariantsToChannelMutation = {
         sku: string;
         taxRateApplied: { id: string; name: string; value: number };
         taxCategory: { id: string; name: string };
-        options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+        options: Array<{
+            id: string;
+            code: string;
+            languageCode: LanguageCode;
+            groupId: string;
+            name: string;
+        }>;
         facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
         featuredAsset?: {
             id: string;
@@ -9234,7 +9295,13 @@ export type RemoveProductVariantsFromChannelMutation = {
         sku: string;
         taxRateApplied: { id: string; name: string; value: number };
         taxCategory: { id: string; name: string };
-        options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+        options: Array<{
+            id: string;
+            code: string;
+            languageCode: LanguageCode;
+            groupId: string;
+            name: string;
+        }>;
         facetValues: Array<{ id: string; code: string; name: string; facet: { id: string; name: string } }>;
         featuredAsset?: {
             id: string;
@@ -10903,6 +10970,7 @@ export type DeleteProductOptionMutation = {
 export type RemoveOptionGroupFromProductMutationVariables = Exact<{
     productId: Scalars['ID'];
     optionGroupId: Scalars['ID'];
+    force?: InputMaybe<Scalars['Boolean']>;
 }>;
 
 export type RemoveOptionGroupFromProductMutation = {
@@ -10953,7 +11021,13 @@ export type GetProductWithVariantListQuery = {
                 sku: string;
                 taxRateApplied: { id: string; name: string; value: number };
                 taxCategory: { id: string; name: string };
-                options: Array<{ id: string; code: string; languageCode: LanguageCode; name: string }>;
+                options: Array<{
+                    id: string;
+                    code: string;
+                    languageCode: LanguageCode;
+                    groupId: string;
+                    name: string;
+                }>;
                 facetValues: Array<{
                     id: string;
                     code: string;
@@ -11903,6 +11977,7 @@ export const ProductVariantFragmentDoc = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -12164,6 +12239,7 @@ export const ProductWithVariantsFragmentDoc = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -22346,6 +22422,7 @@ export const UpdateProductDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -22630,6 +22707,7 @@ export const CreateProductDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -22921,6 +22999,7 @@ export const GetProductWithVariantsDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -23280,6 +23359,7 @@ export const CreateProductVariantsDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -23468,6 +23548,7 @@ export const UpdateProductVariantsDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -25652,6 +25733,7 @@ export const AssignProductsToChannelDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -25939,6 +26021,7 @@ export const RemoveProductsFromChannelDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -26223,6 +26306,7 @@ export const AssignProductVariantsToChannelDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -26408,6 +26492,7 @@ export const RemoveProductVariantsFromChannelDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },
@@ -33575,6 +33660,11 @@ export const RemoveOptionGroupFromProductDocument = {
                         type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
                     },
                 },
+                {
+                    kind: 'VariableDefinition',
+                    variable: { kind: 'Variable', name: { kind: 'Name', value: 'force' } },
+                    type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
+                },
             ],
             selectionSet: {
                 kind: 'SelectionSet',
@@ -33593,6 +33683,11 @@ export const RemoveOptionGroupFromProductDocument = {
                                 name: { kind: 'Name', value: 'optionGroupId' },
                                 value: { kind: 'Variable', name: { kind: 'Name', value: 'optionGroupId' } },
                             },
+                            {
+                                kind: 'Argument',
+                                name: { kind: 'Name', value: 'force' },
+                                value: { kind: 'Variable', name: { kind: 'Name', value: 'force' } },
+                            },
                         ],
                         selectionSet: {
                             kind: 'SelectionSet',
@@ -33907,6 +34002,7 @@ export const GetProductWithVariantListDocument = {
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'languageCode' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
                         },

+ 141 - 3
packages/core/e2e/product.e2e-spec.ts

@@ -1306,7 +1306,7 @@ describe('Product resolver', () => {
             removeOptionGuard.assertErrorResult(removeOptionGroupFromProduct);
 
             expect(removeOptionGroupFromProduct.message).toBe(
-                'Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants',
+                'Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants. Use the `force` argument to remove it anyway',
             );
             expect(removeOptionGroupFromProduct.errorCode).toBe(ErrorCode.PRODUCT_OPTION_IN_USE_ERROR);
             expect(removeOptionGroupFromProduct.optionGroupCode).toBe('curvy-monitor-monitor-size');
@@ -1667,6 +1667,144 @@ describe('Product resolver', () => {
                 ),
             );
 
+            describe('adding options to existing variants', () => {
+                let variantToModify: NonNullable<
+                    Codegen.CreateProductVariantsMutation['createProductVariants'][number]
+                >;
+                let initialOptionIds: string[];
+                let newOptionGroup: Codegen.CreateProductOptionGroupMutation['createProductOptionGroup'];
+
+                beforeAll(() => {
+                    variantToModify = variants[0]!;
+                    initialOptionIds = variantToModify.options.map(o => o.id);
+                });
+                it('assert initial state', async () => {
+                    expect(variantToModify.options.map(o => o.code)).toEqual([
+                        'group2-option-2',
+                        'group3-option-1',
+                    ]);
+                });
+
+                it(
+                    'passing optionIds from an invalid OptionGroup throws',
+                    assertThrowsWithMessage(async () => {
+                        await adminClient.query<
+                            Codegen.UpdateProductVariantsMutation,
+                            Codegen.UpdateProductVariantsMutationVariables
+                        >(UPDATE_PRODUCT_VARIANTS, {
+                            input: [
+                                {
+                                    id: variantToModify.id,
+                                    optionIds: [...variantToModify.options.map(o => o.id), 'T_1'],
+                                },
+                            ],
+                        });
+                    }, 'ProductVariant optionIds must include one optionId from each of the groups: group-2, group-3'),
+                );
+
+                it(
+                    'passing optionIds that match an existing variant throws',
+                    assertThrowsWithMessage(async () => {
+                        await adminClient.query<
+                            Codegen.UpdateProductVariantsMutation,
+                            Codegen.UpdateProductVariantsMutationVariables
+                        >(UPDATE_PRODUCT_VARIANTS, {
+                            input: [
+                                {
+                                    id: variantToModify.id,
+                                    optionIds: variants[1]!.options.map(o => o.id),
+                                },
+                            ],
+                        });
+                    }, 'A ProductVariant with the selected options already exists: Variant 3'),
+                );
+
+                it('addOptionGroupToProduct and then update existing ProductVariant with a new option', async () => {
+                    const optionGroup4 = await createOptionGroup('group-4', [
+                        'group4-option-1',
+                        'group4-option-2',
+                    ]);
+                    newOptionGroup = optionGroup4;
+                    const result = await adminClient.query<
+                        Codegen.AddOptionGroupToProductMutation,
+                        Codegen.AddOptionGroupToProductMutationVariables
+                    >(ADD_OPTION_GROUP_TO_PRODUCT, {
+                        optionGroupId: optionGroup4.id,
+                        productId: newProduct.id,
+                    });
+                    expect(result.addOptionGroupToProduct.optionGroups.length).toBe(3);
+                    expect(result.addOptionGroupToProduct.optionGroups[2].id).toBe(optionGroup4.id);
+
+                    const { updateProductVariants } = await adminClient.query<
+                        Codegen.UpdateProductVariantsMutation,
+                        Codegen.UpdateProductVariantsMutationVariables
+                    >(UPDATE_PRODUCT_VARIANTS, {
+                        input: [
+                            {
+                                id: variantToModify.id,
+                                optionIds: [
+                                    ...variantToModify.options.map(o => o.id),
+                                    optionGroup4.options[0].id,
+                                ],
+                            },
+                        ],
+                    });
+
+                    expect(updateProductVariants[0]!.options.map(o => o.code)).toEqual([
+                        'group2-option-2',
+                        'group3-option-1',
+                        'group4-option-1',
+                    ]);
+                });
+
+                it('removeOptionGroup fails because option is in use', async () => {
+                    const { removeOptionGroupFromProduct } = await adminClient.query<
+                        Codegen.RemoveOptionGroupFromProductMutation,
+                        Codegen.RemoveOptionGroupFromProductMutationVariables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: newOptionGroup.id,
+                        productId: newProduct.id,
+                    });
+                    removeOptionGuard.assertErrorResult(removeOptionGroupFromProduct);
+
+                    expect(removeOptionGroupFromProduct.message).toBe(
+                        'Cannot remove ProductOptionGroup "group-4" as it is used by 3 ProductVariants. Use the `force` argument to remove it anyway',
+                    );
+                });
+
+                it('removeOptionGroup with force argument', async () => {
+                    const { removeOptionGroupFromProduct } = await adminClient.query<
+                        Codegen.RemoveOptionGroupFromProductMutation,
+                        Codegen.RemoveOptionGroupFromProductMutationVariables
+                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                        optionGroupId: newOptionGroup.id,
+                        productId: newProduct.id,
+                        force: true,
+                    });
+                    removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
+
+                    expect(removeOptionGroupFromProduct.optionGroups.length).toBe(2);
+
+                    const { product } = await adminClient.query<
+                        Codegen.GetProductWithVariantsQuery,
+                        Codegen.GetProductWithVariantsQueryVariables
+                    >(GET_PRODUCT_WITH_VARIANTS, {
+                        id: newProduct.id,
+                    });
+                    function assertNoOptionGroup(
+                        variant: Codegen.ProductVariantFragment,
+                        optionGroupId: string,
+                    ) {
+                        expect(variant.options.map(o => o.groupId).every(id => id !== optionGroupId)).toBe(
+                            true,
+                        );
+                    }
+                    assertNoOptionGroup(product!.variants[0]!, newOptionGroup.id);
+                    assertNoOptionGroup(product!.variants[1]!, newOptionGroup.id);
+                    assertNoOptionGroup(product!.variants[2]!, newOptionGroup.id);
+                });
+            });
+
             let deletedVariant: Codegen.ProductVariantFragment;
 
             it('deleteProductVariant', async () => {
@@ -2091,8 +2229,8 @@ describe('Product resolver', () => {
 });
 
 export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
-    mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
-        removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {
+    mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!, $force: Boolean) {
+        removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId, force: $force) {
             ...ProductWithOptions
             ... on ProductOptionInUseError {
                 errorCode

+ 2 - 2
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -176,8 +176,8 @@ export class ProductResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: MutationRemoveOptionGroupFromProductArgs,
     ): Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>> {
-        const { productId, optionGroupId } = args;
-        return this.productService.removeOptionGroupFromProduct(ctx, productId, optionGroupId);
+        const { productId, optionGroupId, force } = args;
+        return this.productService.removeOptionGroupFromProduct(ctx, productId, optionGroupId, force);
     }
 
     @Transaction()

+ 8 - 2
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -28,8 +28,13 @@ type Mutation {
     "Add an OptionGroup to a Product"
     addOptionGroupToProduct(productId: ID!, optionGroupId: ID!): Product!
 
-    "Remove an OptionGroup from a Product"
-    removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): RemoveOptionGroupFromProductResult!
+    """
+    Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+    the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+    Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+    as well as removing any of the group's options from the Product's ProductVariants.
+    """
+    removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!, force: Boolean): RemoveOptionGroupFromProductResult!
 
     "Create a set of ProductVariants based on the OptionGroups assigned to the given Product"
     createProductVariants(input: [CreateProductVariantInput!]!): [ProductVariant]!
@@ -146,6 +151,7 @@ input UpdateProductVariantInput {
     enabled: Boolean
     translations: [ProductVariantTranslationInput!]
     facetValueIds: [ID!]
+    optionIds: [ID!]
     sku: String
     taxCategoryId: ID
     price: Money

+ 1 - 1
packages/core/src/i18n/messages/en.json

@@ -93,7 +93,7 @@
     "PAYMENT_FAILED_ERROR": "The payment failed",
     "PAYMENT_ORDER_MISMATCH_ERROR": "The Payment and OrderLines do not belong to the same Order",
     "PAYMENT_STATE_TRANSITION_ERROR": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
-    "PRODUCT_OPTION_IN_USE_ERROR": "Cannot remove ProductOptionGroup \"{ optionGroupCode }\" as it is used by {productVariantCount, plural, one {1 ProductVariant} other {# ProductVariants}}",
+    "PRODUCT_OPTION_IN_USE_ERROR": "Cannot remove ProductOptionGroup \"{ optionGroupCode }\" as it is used by {productVariantCount, plural, one {1 ProductVariant} other {# ProductVariants}}. Use the `force` argument to remove it anyway",
     "QUANTITY_TOO_GREAT_ERROR": "The specified quantity is greater than the available OrderItems",
     "REFUND_ORDER_STATE_ERROR": "Cannot refund an Order in the \"{ orderState }\" state",
     "SETTLE_PAYMENT_ERROR": "Settling the payment failed",

+ 14 - 6
packages/core/src/service/services/product-variant.service.ts

@@ -384,7 +384,7 @@ export class ProductVariantService {
     }
 
     private async createSingle(ctx: RequestContext, input: CreateProductVariantInput): Promise<ID> {
-        await this.validateVariantOptionIds(ctx, input);
+        await this.validateVariantOptionIds(ctx, input.productId, input.optionIds);
         if (!input.optionIds) {
             input.optionIds = [];
         }
@@ -458,6 +458,9 @@ export class ProductVariantService {
         if (input.stockOnHand && input.stockOnHand < outOfStockThreshold) {
             throw new UserInputError('error.stockonhand-cannot-be-negative');
         }
+        if (input.optionIds) {
+            await this.validateVariantOptionIds(ctx, existingVariant.productId, input.optionIds);
+        }
         const inputWithoutPriceAndStockLevels = {
             ...input,
         };
@@ -475,6 +478,12 @@ export class ProductVariantService {
                         v.taxCategory = taxCategory;
                     }
                 }
+                if (input.optionIds && input.optionIds.length) {
+                    const selectedOptions = await this.connection
+                        .getRepository(ctx, ProductOption)
+                        .find({ where: { id: In(input.optionIds) } });
+                    v.options = selectedOptions;
+                }
                 if (input.facetValueIds) {
                     const facetValuesInOtherChannels = existingVariant.facetValues.filter(fv =>
                         fv.channels.every(channel => !idsAreEqual(channel.id, ctx.channelId)),
@@ -737,18 +746,17 @@ export class ProductVariantService {
         return result;
     }
 
-    private async validateVariantOptionIds(ctx: RequestContext, input: CreateProductVariantInput) {
-        // this could be done with less queries but depending on the data, node will crash
+    private async validateVariantOptionIds(ctx: RequestContext, productId: ID, optionIds: ID[] = []) {
+        // this could be done with fewer queries but depending on the data, node will crash
         // https://github.com/vendure-ecommerce/vendure/issues/328
         const optionGroups = (
-            await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
+            await this.connection.getEntityOrThrow(ctx, Product, productId, {
                 channelId: ctx.channelId,
                 relations: ['optionGroups', 'optionGroups.options'],
                 loadEagerRelations: false,
             })
         ).optionGroups;
 
-        const optionIds = input.optionIds || [];
         const activeOptions = optionGroups && optionGroups.filter(group => !group.deletedAt);
 
         if (optionIds.length !== activeOptions.length) {
@@ -763,7 +771,7 @@ export class ProductVariantService {
             this.throwIncompatibleOptionsError(optionGroups);
         }
 
-        const product = await this.connection.getEntityOrThrow(ctx, Product, input.productId, {
+        const product = await this.connection.getEntityOrThrow(ctx, Product, productId, {
             channelId: ctx.channelId,
             relations: ['variants', 'variants.options'],
             loadEagerRelations: true,

+ 17 - 4
packages/core/src/service/services/product.service.ts

@@ -12,6 +12,7 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
+import { ProductVariant } from '@vendure/core';
 import { FindOptionsUtils, In, IsNull } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -397,6 +398,7 @@ export class ProductService {
         ctx: RequestContext,
         productId: ID,
         optionGroupId: ID,
+        force?: boolean,
     ): Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>> {
         const product = await this.getProductWithOptionGroups(ctx, productId);
         const optionGroup = product.optionGroups.find(g => idsAreEqual(g.id, optionGroupId));
@@ -409,10 +411,21 @@ export class ProductService {
                 variant.options.some(option => idsAreEqual(option.groupId, optionGroupId)),
         );
         if (optionIsInUse) {
-            return new ProductOptionInUseError({
-                optionGroupCode: optionGroup.code,
-                productVariantCount: product.variants.length,
-            });
+            if (!force) {
+                return new ProductOptionInUseError({
+                    optionGroupCode: optionGroup.code,
+                    productVariantCount: product.variants.length,
+                });
+            } else {
+                // We will force the removal of this ProductOptionGroup by first
+                // removing all ProductOptions from the ProductVariants
+                for (const variant of product.variants) {
+                    variant.options = variant.options.filter(o => !idsAreEqual(o.groupId, optionGroupId));
+                }
+                await this.connection.getRepository(ctx, ProductVariant).save(product.variants, {
+                    reload: false,
+                });
+            }
         }
         const result = await this.productOptionGroupService.deleteGroupAndOptionsFromProduct(
             ctx,

+ 8 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -2661,7 +2661,12 @@ export type Mutation = {
     removeFacetsFromChannel: Array<RemoveFacetFromChannelResult>;
     /** Remove members from a Zone */
     removeMembersFromZone: Zone;
-    /** Remove an OptionGroup from a Product */
+    /**
+     * Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+     * the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+     * Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+     * as well as removing any of the group's options from the Product's ProductVariants.
+     */
     removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Removes ProductVariants from the specified Channel */
     removeProductVariantsFromChannel: Array<ProductVariant>;
@@ -3178,6 +3183,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 };
 
 export type MutationRemoveOptionGroupFromProductArgs = {
+    force?: InputMaybe<Scalars['Boolean']>;
     optionGroupId: Scalars['ID'];
     productId: Scalars['ID'];
 };
@@ -5823,6 +5829,7 @@ export type UpdateProductVariantInput = {
     facetValueIds?: InputMaybe<Array<Scalars['ID']>>;
     featuredAssetId?: InputMaybe<Scalars['ID']>;
     id: Scalars['ID'];
+    optionIds?: InputMaybe<Array<Scalars['ID']>>;
     outOfStockThreshold?: InputMaybe<Scalars['Int']>;
     price?: InputMaybe<Scalars['Money']>;
     sku?: InputMaybe<Scalars['String']>;

+ 8 - 1
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -2661,7 +2661,12 @@ export type Mutation = {
     removeFacetsFromChannel: Array<RemoveFacetFromChannelResult>;
     /** Remove members from a Zone */
     removeMembersFromZone: Zone;
-    /** Remove an OptionGroup from a Product */
+    /**
+     * Remove an OptionGroup from a Product. If the OptionGroup is in use by any ProductVariants
+     * the mutation will return a ProductOptionInUseError, and the OptionGroup will not be removed.
+     * Setting the `force` argument to `true` will override this and remove the OptionGroup anyway,
+     * as well as removing any of the group's options from the Product's ProductVariants.
+     */
     removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Removes ProductVariants from the specified Channel */
     removeProductVariantsFromChannel: Array<ProductVariant>;
@@ -3178,6 +3183,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 };
 
 export type MutationRemoveOptionGroupFromProductArgs = {
+    force?: InputMaybe<Scalars['Boolean']>;
     optionGroupId: Scalars['ID'];
     productId: Scalars['ID'];
 };
@@ -5823,6 +5829,7 @@ export type UpdateProductVariantInput = {
     facetValueIds?: InputMaybe<Array<Scalars['ID']>>;
     featuredAssetId?: InputMaybe<Scalars['ID']>;
     id: Scalars['ID'];
+    optionIds?: InputMaybe<Array<Scalars['ID']>>;
     outOfStockThreshold?: InputMaybe<Scalars['Int']>;
     price?: InputMaybe<Scalars['Money']>;
     sku?: InputMaybe<Scalars['String']>;

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
schema-admin.json


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff