Skip to content

Commit

Permalink
feat(core): Allow variant options to be added & removed
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed May 29, 2023
1 parent e6c8f0c commit 8cb9b27
Show file tree
Hide file tree
Showing 13 changed files with 326 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -3178,6 +3183,7 @@ export type MutationRemoveMembersFromZoneArgs = {
};

export type MutationRemoveOptionGroupFromProductArgs = {
force?: InputMaybe<Scalars['Boolean']>;
optionGroupId: Scalars['ID'];
productId: Scalars['ID'];
};
Expand Down Expand Up @@ -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']>;
Expand Down
9 changes: 8 additions & 1 deletion packages/common/src/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -3326,6 +3331,7 @@ export type MutationRemoveMembersFromZoneArgs = {


export type MutationRemoveOptionGroupFromProductArgs = {
force?: InputMaybe<Scalars['Boolean']>;
optionGroupId: Scalars['ID'];
productId: Scalars['ID'];
};
Expand Down Expand Up @@ -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']>;
Expand Down
1 change: 1 addition & 0 deletions packages/core/e2e/graphql/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
id
code
languageCode
groupId
name
}
facetValues {
Expand Down
122 changes: 109 additions & 13 deletions packages/core/e2e/graphql/generated-e2e-admin-types.ts

Large diffs are not rendered by default.

144 changes: 141 additions & 3 deletions packages/core/e2e/product.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/api/resolvers/admin/product.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/api/schema/admin-api/product.api.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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]!
Expand Down Expand Up @@ -146,6 +151,7 @@ input UpdateProductVariantInput {
enabled: Boolean
translations: [ProductVariantTranslationInput!]
facetValueIds: [ID!]
optionIds: [ID!]
sku: String
taxCategoryId: ID
price: Money
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/service/services/product-variant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
}
Expand Down Expand Up @@ -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,
};
Expand All @@ -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)),
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/service/services/product.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 8cb9b27

Please sign in to comment.