Browse Source

feat(core): Add `ProductVariant.product` field & resolver

Relates to #378
Michael Bromley 5 years ago
parent
commit
03348484d9

+ 6 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1786,6 +1786,7 @@ export type Mutation = {
   assignProductsToChannel: Array<Product>;
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator;
+  /** Authenticates the user using a named authentication strategy */
   authenticate: LoginResult;
   cancelOrder: Order;
   /** Create a new Administrator */
@@ -1868,7 +1869,10 @@ export type Mutation = {
   deleteZone: DeletionResponse;
   fulfillOrder: Fulfillment;
   importProducts?: Maybe<ImportInfo>;
-  /** @deprecated Use `authenticate` mutation with the 'native' strategy instead. */
+  /**
+   * Authenticates the user using the native authentication strategy. This mutation
+   * is an alias for `authenticate({ native: { ... }})`
+   */
   login: LoginResult;
   logout: Scalars['Boolean'];
   /** Move a Collection to a different parent or index */
@@ -2811,6 +2815,7 @@ export type ProductVariant = Node & {
   trackInventory: Scalars['Boolean'];
   stockMovements: StockMovementList;
   id: Scalars['ID'];
+  product: Product;
   productId: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];

+ 6 - 6
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -97,10 +97,10 @@ const result: IntrospectionResultData = {
                         name: 'Return',
                     },
                     {
-                        name: 'TaxRate',
+                        name: 'Product',
                     },
                     {
-                        name: 'TaxCategory',
+                        name: 'ProductOptionGroup',
                     },
                     {
                         name: 'ProductOption',
@@ -112,16 +112,16 @@ const result: IntrospectionResultData = {
                         name: 'Facet',
                     },
                     {
-                        name: 'Job',
+                        name: 'TaxRate',
                     },
                     {
-                        name: 'PaymentMethod',
+                        name: 'TaxCategory',
                     },
                     {
-                        name: 'Product',
+                        name: 'Job',
                     },
                     {
-                        name: 'ProductOptionGroup',
+                        name: 'PaymentMethod',
                     },
                 ],
             },

+ 6 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1783,7 +1783,12 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /**
+     * Authenticates the user using the native authentication strategy. This mutation
+     * is an alias for `authenticate({ native: { ... }})`
+     */
     login: LoginResult;
+    /** Authenticates the user using a named authentication strategy */
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -2684,6 +2689,7 @@ export type ProductVariant = Node & {
     trackInventory: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
+    product: Product;
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];

+ 6 - 1
packages/common/src/generated-shop-types.ts

@@ -1356,8 +1356,12 @@ export type Mutation = {
     setOrderShippingMethod?: Maybe<Order>;
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
-    /** @deprecated Use `authenticate` mutation with the 'native' strategy instead. */
+    /**
+     * Authenticates the user using the native authentication strategy. This mutation
+     * is an alias for `authenticate({ native: { ... }})`
+     */
     login: LoginResult;
+    /** Authenticates the user using a named authentication strategy */
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /**
@@ -1857,6 +1861,7 @@ export type ProductTranslation = {
 export type ProductVariant = Node & {
     __typename?: 'ProductVariant';
     id: Scalars['ID'];
+    product: Product;
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];

+ 10 - 4
packages/common/src/generated-types.ts

@@ -605,7 +605,7 @@ export type CreateZoneInput = {
 /**
  * @description
  * ISO 4217 currency code
- *
+ * 
  * @docsCategory common
  */
 export enum CurrencyCode {
@@ -1393,7 +1393,7 @@ export type JobSortParameter = {
 /**
  * @description
  * The state of a Job in the JobQueue
- *
+ * 
  * @docsCategory common
  */
 export enum JobState {
@@ -1411,7 +1411,7 @@ export enum JobState {
  * region or script modifier (e.g. de_AT). The selection available is based
  * on the [Unicode CLDR summary list](https://unicode-org.github.io/cldr-staging/charts/37/summary/root.html)
  * and includes the major spoken languages of the world and any widely-used variants.
- *
+ * 
  * @docsCategory common
  */
 export enum LanguageCode {
@@ -1782,7 +1782,12 @@ export type Mutation = {
   deleteAsset: DeletionResponse;
   /** Delete multiple Assets */
   deleteAssets: DeletionResponse;
+  /**
+   * Authenticates the user using the native authentication strategy. This mutation
+   * is an alias for `authenticate({ native: { ... }})`
+   */
   login: LoginResult;
+  /** Authenticates the user using a named authentication strategy */
   authenticate: LoginResult;
   logout: Scalars['Boolean'];
   /** Create a new Channel */
@@ -2587,7 +2592,7 @@ export type PaymentMethodSortParameter = {
  * @description
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
- *
+ * 
  * @docsCategory common
  */
 export enum Permission {
@@ -2770,6 +2775,7 @@ export type ProductVariant = Node & {
   trackInventory: Scalars['Boolean'];
   stockMovements: StockMovementList;
   id: Scalars['ID'];
+  product: Product;
   productId: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];

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

@@ -1783,7 +1783,12 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /**
+     * Authenticates the user using the native authentication strategy. This mutation
+     * is an alias for `authenticate({ native: { ... }})`
+     */
     login: LoginResult;
+    /** Authenticates the user using a named authentication strategy */
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -2684,6 +2689,7 @@ export type ProductVariant = Node & {
     trackInventory: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
+    product: Product;
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -5406,6 +5412,10 @@ export type DeleteRoleMutation = { __typename?: 'Mutation' } & {
     deleteRole: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>;
 };
 
+export type LogoutMutationVariables = {};
+
+export type LogoutMutation = { __typename?: 'Mutation' } & Pick<Mutation, 'logout'>;
+
 export type ShippingMethodFragment = { __typename?: 'ShippingMethod' } & Pick<
     ShippingMethod,
     'id' | 'code' | 'description'
@@ -6955,6 +6965,11 @@ export namespace DeleteRole {
     export type DeleteRole = DeleteRoleMutation['deleteRole'];
 }
 
+export namespace Logout {
+    export type Variables = LogoutMutationVariables;
+    export type Mutation = LogoutMutation;
+}
+
 export namespace ShippingMethod {
     export type Fragment = ShippingMethodFragment;
     export type Calculator = ShippingMethodFragment['calculator'];

+ 6 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1356,8 +1356,12 @@ export type Mutation = {
     setOrderShippingMethod?: Maybe<Order>;
     addPaymentToOrder?: Maybe<Order>;
     setCustomerForOrder?: Maybe<Order>;
-    /** @deprecated Use `authenticate` mutation with the 'native' strategy instead. */
+    /**
+     * Authenticates the user using the native authentication strategy. This mutation
+     * is an alias for `authenticate({ native: { ... }})`
+     */
     login: LoginResult;
+    /** Authenticates the user using a named authentication strategy */
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /**
@@ -1857,6 +1861,7 @@ export type ProductTranslation = {
 export type ProductVariant = Node & {
     __typename?: 'ProductVariant';
     id: Scalars['ID'];
+    product: Product;
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];

+ 1 - 1
packages/core/e2e/session-management.e2e-spec.ts

@@ -102,7 +102,7 @@ describe('Session caching', () => {
 
         await adminClient.query(
             gql`
-                mutation {
+                mutation Logout {
                     logout
                 }
             `,

+ 5 - 4
packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -1,7 +1,5 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
-import { Translated } from '../../../common/types/locale-types';
-import { assertFound } from '../../../common/utils';
 import { Asset, OrderLine, ProductVariant } from '../../../entity';
 import { AssetService, ProductVariantService } from '../../../service';
 import { RequestContext } from '../../common/request-context';
@@ -15,8 +13,11 @@ export class OrderLineEntityResolver {
     async productVariant(
         @Ctx() ctx: RequestContext,
         @Parent() orderLine: OrderLine,
-    ): Promise<Translated<ProductVariant>> {
-        return assertFound(this.productVariantService.findOne(ctx, orderLine.productVariant.id));
+    ): Promise<ProductVariant> {
+        if (orderLine.productVariant) {
+            return orderLine.productVariant;
+        }
+        return this.productVariantService.getVariantByOrderLineId(ctx, orderLine.id);
     }
 
     @ResolveField()

+ 13 - 2
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -3,7 +3,7 @@ import { StockMovementListOptions } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
-import { Asset, FacetValue, ProductOption } from '../../../entity';
+import { Asset, FacetValue, Product, ProductOption } from '../../../entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
 import { AssetService } from '../../../service/services/asset.service';
@@ -18,6 +18,17 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class ProductVariantEntityResolver {
     constructor(private productVariantService: ProductVariantService, private assetService: AssetService) {}
 
+    @ResolveField()
+    async product(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<Product | undefined> {
+        if (productVariant.product) {
+            return productVariant.product;
+        }
+        return this.productVariantService.getProductForVariant(ctx, productVariant);
+    }
+
     @ResolveField()
     async assets(
         @Ctx() ctx: RequestContext,
@@ -61,7 +72,7 @@ export class ProductVariantEntityResolver {
             facetValues = await this.productVariantService.getFacetValuesForVariant(ctx, productVariant.id);
         }
         if (apiType === 'shop') {
-            facetValues = facetValues.filter(fv => !fv.facet.isPrivate);
+            facetValues = facetValues.filter((fv) => !fv.facet.isPrivate);
         }
         return facetValues;
     }

+ 1 - 0
packages/core/src/api/schema/type/product.type.graphql

@@ -32,6 +32,7 @@ type ProductList implements PaginatedList {
 
 type ProductVariant implements Node {
     id: ID!
+    product: Product!
     productId: ID!
     createdAt: DateTime!
     updatedAt: DateTime!

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

@@ -15,7 +15,7 @@ import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
+import { OrderLine, ProductOptionGroup, ProductVariantPrice, TaxCategory } from '../../entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { ProductOption } from '../../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity';
@@ -61,9 +61,11 @@ export class ProductVariantService {
         return this.connection
             .getRepository(ProductVariant)
             .findOne(productVariantId, { relations })
-            .then(result => {
+            .then((result) => {
                 if (result) {
-                    return translateDeep(this.applyChannelPriceAndTax(result, ctx), ctx.languageCode);
+                    return translateDeep(this.applyChannelPriceAndTax(result, ctx), ctx.languageCode, [
+                        'product',
+                    ]);
                 }
             });
     }
@@ -81,8 +83,8 @@ export class ProductVariantService {
                     'featuredAsset',
                 ],
             })
-            .then(variants => {
-                return variants.map(variant =>
+            .then((variants) => {
+                return variants.map((variant) =>
                     translateDeep(this.applyChannelPriceAndTax(variant, ctx), ctx.languageCode, [
                         'options',
                         'facetValues',
@@ -112,8 +114,8 @@ export class ProductVariantService {
                     id: 'ASC',
                 },
             })
-            .then(variants =>
-                variants.map(variant => {
+            .then((variants) =>
+                variants.map((variant) => {
                     const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
                     return translateDeep(variantWithPrices, ctx.languageCode, [
                         'options',
@@ -144,7 +146,7 @@ export class ProductVariantService {
         }
 
         return qb.getManyAndCount().then(async ([variants, totalItems]) => {
-            const items = variants.map(variant => {
+            const items = variants.map((variant) => {
                 const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
                 return translateDeep(variantWithPrices, ctx.languageCode);
             });
@@ -155,22 +157,41 @@ export class ProductVariantService {
         });
     }
 
+    async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Translated<ProductVariant>> {
+        const { productVariant } = await getEntityOrThrow(this.connection, OrderLine, orderLineId, {
+            relations: ['productVariant'],
+        });
+        return translateDeep(productVariant, ctx.languageCode);
+    }
+
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
         return this.connection
             .getRepository(ProductVariant)
             .findOne(variantId, { relations: ['options'] })
-            .then(variant => (!variant ? [] : variant.options.map(o => translateDeep(o, ctx.languageCode))));
+            .then((variant) =>
+                !variant ? [] : variant.options.map((o) => translateDeep(o, ctx.languageCode)),
+            );
     }
 
     getFacetValuesForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<FacetValue>>> {
         return this.connection
             .getRepository(ProductVariant)
             .findOne(variantId, { relations: ['facetValues', 'facetValues.facet'] })
-            .then(variant =>
-                !variant ? [] : variant.facetValues.map(o => translateDeep(o, ctx.languageCode, ['facet'])),
+            .then((variant) =>
+                !variant ? [] : variant.facetValues.map((o) => translateDeep(o, ctx.languageCode, ['facet'])),
             );
     }
 
+    /**
+     * Returns the Product associated with the ProductVariant. Whereas the `ProductService.findOne()`
+     * method performs a large multi-table join with all the typical data needed for a "product detail"
+     * page, this method returns on the Product itself.
+     */
+    async getProductForVariant(ctx: RequestContext, variant: ProductVariant): Promise<Translated<Product>> {
+        const product = await getEntityOrThrow(this.connection, Product, variant.productId);
+        return translateDeep(product, ctx.languageCode);
+    }
+
     async create(
         ctx: RequestContext,
         input: CreateProductVariantInput[],
@@ -194,7 +215,7 @@ export class ProductVariantService {
         }
         const updatedVariants = await this.findByIds(
             ctx,
-            input.map(i => i.id),
+            input.map((i) => i.id),
         );
         this.eventBus.publish(new ProductVariantEvent(ctx, updatedVariants, 'updated'));
         return updatedVariants;
@@ -214,7 +235,7 @@ export class ProductVariantService {
             input,
             entityType: ProductVariant,
             translationType: ProductVariantTranslation,
-            beforeSave: async variant => {
+            beforeSave: async (variant) => {
                 const { optionIds } = input;
                 if (optionIds && optionIds.length) {
                     const selectedOptions = await this.connection
@@ -259,7 +280,7 @@ export class ProductVariantService {
             input,
             entityType: ProductVariant,
             translationType: ProductVariantTranslation,
-            beforeSave: async updatedVariant => {
+            beforeSave: async (updatedVariant) => {
                 if (input.taxCategoryId) {
                     const taxCategory = await this.taxCategoryService.findOne(input.taxCategoryId);
                     if (taxCategory) {
@@ -331,7 +352,9 @@ export class ProductVariantService {
      * Populates the `price` field with the price for the specified channel.
      */
     applyChannelPriceAndTax(variant: ProductVariant, ctx: RequestContext): ProductVariant {
-        const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, ctx.channelId));
+        const channelPrice = variant.productVariantPrices.find((p) =>
+            idsAreEqual(p.channelId, ctx.channelId),
+        );
         if (!channelPrice) {
             throw new InternalServerError(`error.no-price-found-for-channel`);
         }
@@ -404,7 +427,7 @@ export class ProductVariantService {
 
         const inputOptionIds = this.sortJoin(optionIds, ',');
 
-        product.variants.forEach(variant => {
+        product.variants.forEach((variant) => {
             const variantOptionIds = this.sortJoin(variant.options, ',', 'id');
             if (variantOptionIds === inputOptionIds) {
                 throw new UserInputError('error.product-variant-options-combination-already-exists', {
@@ -422,7 +445,7 @@ export class ProductVariantService {
 
     private sortJoin<T>(arr: T[], glue: string, prop?: keyof T): string {
         return arr
-            .map(x => (prop ? x[prop] : x))
+            .map((x) => (prop ? x[prop] : x))
             .sort()
             .join(glue);
     }

+ 6 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1783,7 +1783,12 @@ export type Mutation = {
     deleteAsset: DeletionResponse;
     /** Delete multiple Assets */
     deleteAssets: DeletionResponse;
+    /**
+     * Authenticates the user using the native authentication strategy. This mutation
+     * is an alias for `authenticate({ native: { ... }})`
+     */
     login: LoginResult;
+    /** Authenticates the user using a named authentication strategy */
     authenticate: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -2684,6 +2689,7 @@ export type ProductVariant = Node & {
     trackInventory: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
+    product: Product;
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];

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


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


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