Browse Source

feat(core): Implement removeProductsFromChannel mutation

Michael Bromley 6 years ago
parent
commit
6a165dca99

+ 28 - 0
packages/common/src/generated-types.ts

@@ -162,6 +162,12 @@ export enum AssetType {
   BINARY = 'BINARY'
 }
 
+export type AssignProductsToChannelInput = {
+  productIds: Array<Scalars['ID']>,
+  channelId: Scalars['ID'],
+  priceFactor?: Maybe<Scalars['Float']>,
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
   __typename?: 'BooleanCustomFieldConfig',
   name: Scalars['String'],
@@ -1758,6 +1764,10 @@ export type Mutation = {
   updateProductVariants: Array<Maybe<ProductVariant>>,
   /** Delete a ProductVariant */
   deleteProductVariant: DeletionResponse,
+  /** Assigns Products to the specified Channel */
+  assignProductsToChannel: Array<Product>,
+  /** Removes Products from the specified Channel */
+  removeProductsFromChannel: Array<Product>,
   createPromotion: Promotion,
   updatePromotion: Promotion,
   deletePromotion: DeletionResponse,
@@ -2058,6 +2068,16 @@ export type MutationDeleteProductVariantArgs = {
 };
 
 
+export type MutationAssignProductsToChannelArgs = {
+  input: AssignProductsToChannelInput
+};
+
+
+export type MutationRemoveProductsFromChannelArgs = {
+  input: RemoveProductsFromChannelInput
+};
+
+
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput
 };
@@ -2403,6 +2423,7 @@ export type PriceRange = {
 export type Product = Node & {
   __typename?: 'Product',
   enabled: Scalars['Boolean'],
+  channels: Array<Channel>,
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
@@ -2934,6 +2955,11 @@ export type RefundOrderInput = {
   reason?: Maybe<Scalars['String']>,
 };
 
+export type RemoveProductsFromChannelInput = {
+  productIds: Array<Scalars['ID']>,
+  channelId: Scalars['ID'],
+};
+
 export type Return = Node & StockMovement & {
   __typename?: 'Return',
   id: Scalars['ID'],
@@ -3020,6 +3046,8 @@ export type SearchResponse = {
 export type SearchResult = {
   __typename?: 'SearchResult',
   enabled: Scalars['Boolean'],
+  /** An array of ids of the Collections in which this result appears */
+  channelIds: Array<Scalars['ID']>,
   sku: Scalars['String'],
   slug: Scalars['String'],
   productId: Scalars['ID'],

+ 18 - 0
packages/core/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -3,6 +3,12 @@
 exports[`Product resolver product mutation createProduct creates a new Product 1`] = `
 Object {
   "assets": Array [],
+  "channels": Array [
+    Object {
+      "code": "__default_channel__",
+      "id": "T_1",
+    },
+  ],
   "description": "A baked potato",
   "enabled": true,
   "facetValues": Array [],
@@ -33,6 +39,12 @@ Object {
 exports[`Product resolver product mutation updateProduct updates a Product 1`] = `
 Object {
   "assets": Array [],
+  "channels": Array [
+    Object {
+      "code": "__default_channel__",
+      "id": "T_1",
+    },
+  ],
   "description": "A blob of mashed potato",
   "enabled": true,
   "facetValues": Array [],
@@ -73,6 +85,12 @@ Object {
       "type": "IMAGE",
     },
   ],
+  "channels": Array [
+    Object {
+      "code": "__default_channel__",
+      "id": "T_1",
+    },
+  ],
   "description": "Discover a truly immersive viewing experience with this monitor curved more deeply than any other. Wrapping around your field of vision the 1,800 R screencreates a wider field of view, enhances depth perception, and minimises peripheral distractions to draw you deeper in to your content.",
   "enabled": true,
   "facetValues": Array [

+ 55 - 1
packages/core/e2e/channel.e2e-spec.ts

@@ -19,6 +19,7 @@ import {
     LanguageCode,
     Me,
     Permission,
+    RemoveProductsFromChannel,
     UpdateProduct,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -260,7 +261,6 @@ describe('Channels', () => {
         it(
             'throws if attempting to assign Product to channel to which the admin has no access',
             assertThrowsWithMessage(async () => {
-                expect(product1).toBeDefined();
                 await adminClient.asUserWithCredentials('admin2@test.com', 'test');
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                     ASSIGN_PRODUCT_TO_CHANNEL,
@@ -305,6 +305,51 @@ describe('Channels', () => {
                 product1.variants.map(v => v.price * PRICE_FACTOR),
             );
         });
+
+        it('does not assign Product to same channel twice', async () => {
+            const { assignProductsToChannel } = await adminClient.query<
+                AssignProductsToChannel.Mutation,
+                AssignProductsToChannel.Variables
+            >(ASSIGN_PRODUCT_TO_CHANNEL, {
+                input: {
+                    channelId: 'T_2',
+                    productIds: [product1.id],
+                },
+            });
+
+            expect(assignProductsToChannel[0].channels.map(c => c.id)).toEqual(['T_1', 'T_2']);
+        });
+
+        it(
+            'throws if attempting to remove Product from default Channel',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<
+                    RemoveProductsFromChannel.Mutation,
+                    RemoveProductsFromChannel.Variables
+                >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                    input: {
+                        productIds: [product1.id],
+                        channelId: 'T_1',
+                    },
+                });
+            }, 'Products cannot be removed from the default Channel'),
+        );
+
+        it('removes Product from Channel', async () => {
+            await adminClient.asSuperAdmin();
+            await adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { removeProductsFromChannel } = await adminClient.query<
+                RemoveProductsFromChannel.Mutation,
+                RemoveProductsFromChannel.Variables
+            >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                input: {
+                    productIds: [product1.id],
+                    channelId: 'T_2',
+                },
+            });
+
+            expect(removeProductsFromChannel[0].channels.map(c => c.id)).toEqual(['T_1']);
+        });
     });
 });
 
@@ -316,3 +361,12 @@ const ASSIGN_PRODUCT_TO_CHANNEL = gql`
     }
     ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;
+
+const REMOVE_PRODUCT_FROM_CHANNEL = gql`
+    mutation RemoveProductsFromChannel($input: RemoveProductsFromChannelInput!) {
+        removeProductsFromChannel(input: $input) {
+            ...ProductWithVariants
+        }
+    }
+    ${PRODUCT_WITH_VARIANTS_FRAGMENT}
+`;

+ 27 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1769,6 +1769,8 @@ export type Mutation = {
     deleteProductVariant: DeletionResponse;
     /** Assigns Products to the specified Channel */
     assignProductsToChannel: Array<Product>;
+    /** Removes Products from the specified Channel */
+    removeProductsFromChannel: Array<Product>;
     createPromotion: Promotion;
     updatePromotion: Promotion;
     deletePromotion: DeletionResponse;
@@ -2021,6 +2023,10 @@ export type MutationAssignProductsToChannelArgs = {
     input: AssignProductsToChannelInput;
 };
 
+export type MutationRemoveProductsFromChannelArgs = {
+    input: RemoveProductsFromChannelInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -2843,6 +2849,11 @@ export type RefundOrderInput = {
     reason?: Maybe<Scalars['String']>;
 };
 
+export type RemoveProductsFromChannelInput = {
+    productIds: Array<Scalars['ID']>;
+    channelId: Scalars['ID'];
+};
+
 export type Return = Node &
     StockMovement & {
         __typename?: 'Return';
@@ -2931,6 +2942,8 @@ export type SearchResponse = {
 export type SearchResult = {
     __typename?: 'SearchResult';
     enabled: Scalars['Boolean'];
+    /** An array of ids of the Collections in which this result appears */
+    channelIds: Array<Scalars['ID']>;
     sku: Scalars['String'];
     slug: Scalars['String'];
     productId: Scalars['ID'];
@@ -3425,6 +3438,14 @@ export type AssignProductsToChannelMutation = { __typename?: 'Mutation' } & {
     assignProductsToChannel: Array<{ __typename?: 'Product' } & ProductWithVariantsFragment>;
 };
 
+export type RemoveProductsFromChannelMutationVariables = {
+    input: RemoveProductsFromChannelInput;
+};
+
+export type RemoveProductsFromChannelMutation = { __typename?: 'Mutation' } & {
+    removeProductsFromChannel: Array<{ __typename?: 'Product' } & ProductWithVariantsFragment>;
+};
+
 export type GetCollectionsWithAssetsQueryVariables = {};
 
 export type GetCollectionsWithAssetsQuery = { __typename?: 'Query' } & {
@@ -5092,6 +5113,12 @@ export namespace AssignProductsToChannel {
     export type AssignProductsToChannel = ProductWithVariantsFragment;
 }
 
+export namespace RemoveProductsFromChannel {
+    export type Variables = RemoveProductsFromChannelMutationVariables;
+    export type Mutation = RemoveProductsFromChannelMutation;
+    export type RemoveProductsFromChannel = ProductWithVariantsFragment;
+}
+
 export namespace GetCollectionsWithAssets {
     export type Variables = GetCollectionsWithAssetsQueryVariables;
     export type Query = GetCollectionsWithAssetsQuery;

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

@@ -8,6 +8,7 @@ import {
     MutationDeleteProductArgs,
     MutationDeleteProductVariantArgs,
     MutationRemoveOptionGroupFromProductArgs,
+    MutationRemoveProductsFromChannelArgs,
     MutationUpdateProductArgs,
     MutationUpdateProductVariantsArgs,
     Permission,
@@ -149,4 +150,13 @@ export class ProductResolver {
     ): Promise<Array<Translated<Product>>> {
         return this.productService.assignProductsToChannel(ctx, args.input);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async removeProductsFromChannel(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRemoveProductsFromChannelArgs,
+    ): Promise<Array<Translated<Product>>> {
+        return this.productService.removeProductsFromChannel(ctx, args.input);
+    }
 }

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

@@ -31,6 +31,9 @@ type Mutation {
 
     "Assigns Products to the specified Channel"
     assignProductsToChannel(input: AssignProductsToChannelInput!): [Product!]!
+
+    "Removes Products from the specified Channel"
+    removeProductsFromChannel(input: RemoveProductsFromChannelInput!): [Product!]!
 }
 
 type Product implements Node {
@@ -129,3 +132,8 @@ input AssignProductsToChannelInput {
     channelId: ID!
     priceFactor: Float
 }
+
+input RemoveProductsFromChannelInput {
+    productIds: [ID!]!
+    channelId: ID!
+}

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

@@ -46,6 +46,7 @@
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",
     "permission-invalid": "The permission \"{ permission }\" is not valid",
+    "products-cannot-be-removed-from-default-channel": "Products cannot be removed from the default Channel",
     "product-id-or-slug-must-be-provided": "Either the product id or slug must be provided",
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",

+ 26 - 0
packages/core/src/service/helpers/utils/find-by-ids-in-channel.ts

@@ -0,0 +1,26 @@
+import { ID, Type } from '@vendure/common/lib/shared-types';
+import { Connection, FindManyOptions, FindOptionsUtils } from 'typeorm';
+
+import { ChannelAware } from '../../../common/types/common-types';
+import { VendureEntity } from '../../../entity';
+
+/**
+ * Like the TypeOrm `Repository.findByIds()` method, but limits the results to
+ * the given Channel.
+ */
+export function findByIdsInChannel<T extends ChannelAware | VendureEntity>(
+    connection: Connection,
+    entity: Type<T>,
+    ids: ID[],
+    channelId: ID,
+    findOptions?: FindManyOptions<T>,
+) {
+    const qb = connection.getRepository(entity).createQueryBuilder('product');
+    FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, findOptions);
+    // tslint:disable-next-line:no-non-null-assertion
+    FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
+    return qb
+        .leftJoin('product.channels', 'channel')
+        .andWhere('channel.id = :channelId', { channelId })
+        .getMany();
+}

+ 1 - 0
packages/core/src/service/index.ts

@@ -1,6 +1,7 @@
 export * from './helpers/job-manager/job';
 export * from './helpers/utils/translate-entity';
 export * from './helpers/utils/patch-entity';
+export * from './helpers/utils/find-by-ids-in-channel';
 export * from './helpers/utils/get-entity-or-throw';
 export * from './helpers/list-query-builder/list-query-builder';
 export * from './helpers/order-state-machine/order-state';

+ 19 - 1
packages/core/src/service/services/channel.service.ts

@@ -10,7 +10,7 @@ import { RequestContext } from '../../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { ChannelNotFoundError, EntityNotFoundError, InternalServerError } from '../../common/error/errors';
 import { ChannelAware } from '../../common/types/common-types';
-import { assertFound } from '../../common/utils';
+import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { VendureEntity } from '../../entity/base/base.entity';
 import { Channel } from '../../entity/channel/channel.entity';
@@ -62,6 +62,24 @@ export class ChannelService {
         return entity;
     }
 
+    /**
+     * Removes the entity from the given Channels and saves.
+     */
+    async removeFromChannels<T extends ChannelAware & VendureEntity>(
+        entityType: Type<T>,
+        entityId: ID,
+        channelIds: ID[],
+    ): Promise<T> {
+        const entity = await getEntityOrThrow(this.connection, entityType, entityId, {
+            relations: ['channels'],
+        });
+        for (const id of channelIds) {
+            entity.channels = entity.channels.filter(c => !idsAreEqual(c.id, id));
+        }
+        await this.connection.getRepository(entityType).save(entity as any);
+        return entity;
+    }
+
     /**
      * Given a channel token, returns the corresponding Channel if it exists.
      */

+ 36 - 8
packages/core/src/service/services/product.service.ts

@@ -6,6 +6,7 @@ import {
     DeletionResponse,
     DeletionResult,
     Permission,
+    RemoveProductsFromChannelInput,
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
 import { normalizeString } from '@vendure/common/lib/normalize-string';
@@ -25,6 +26,7 @@ import { EventBus } from '../../event-bus/event-bus';
 import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { findByIdsInChannel } from '../helpers/utils/find-by-ids-in-channel';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
@@ -89,6 +91,16 @@ export class ProductService {
         return translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]);
     }
 
+    async findByIds(ctx: RequestContext, productIds: ID[]): Promise<Array<Translated<Product>>> {
+        return findByIdsInChannel(this.connection, Product, productIds, ctx.channelId, {
+            relations: this.relations,
+        }).then(products =>
+            products.map(product =>
+                translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]),
+            ),
+        );
+    }
+
     async getProductChannels(productId: ID): Promise<Channel[]> {
         const product = await getEntityOrThrow(this.connection, Product, productId, {
             relations: ['channels'],
@@ -184,16 +196,32 @@ export class ProductService {
                     input.channelId,
                 );
             }
+            this.eventBus.publish(new CatalogModificationEvent(ctx, product));
         }
+        return this.findByIds(ctx, productsWithVariants.map(p => p.id));
+    }
 
-        return this.connection
-            .getRepository(Product)
-            .findByIds(productsWithVariants.map(p => p.id), { relations: this.relations })
-            .then(products =>
-                products.map(product =>
-                    translateDeep(product, ctx.languageCode, ['facetValues', ['facetValues', 'facet']]),
-                ),
-            );
+    async removeProductsFromChannel(
+        ctx: RequestContext,
+        input: RemoveProductsFromChannelInput,
+    ): Promise<Array<Translated<Product>>> {
+        const hasPermission = await this.roleService.userHasPermissionOnChannel(
+            ctx.activeUserId,
+            input.channelId,
+            Permission.UpdateCatalog,
+        );
+        if (!hasPermission) {
+            throw new ForbiddenError();
+        }
+        if (idsAreEqual(input.channelId, this.channelService.getDefaultChannel().id)) {
+            throw new UserInputError('error.products-cannot-be-removed-from-default-channel');
+        }
+        const products = await this.connection.getRepository(Product).findByIds(input.productIds);
+        for (const product of products) {
+            await this.channelService.removeFromChannels(Product, product.id, [input.channelId]);
+            this.eventBus.publish(new CatalogModificationEvent(ctx, product));
+        }
+        return this.findByIds(ctx, products.map(p => p.id));
     }
 
     async addOptionGroupToProduct(

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


Some files were not shown because too many files changed in this diff