Browse Source

Merge branch 'minor' into major

Michael Bromley 3 years ago
parent
commit
9d4587c919
20 changed files with 773 additions and 301 deletions
  1. 10 0
      CHANGELOG.md
  2. 1 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  3. 15 2
      packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.html
  4. 25 0
      packages/admin-ui/src/lib/core/src/shared/pipes/custom-field-description.pipe.ts
  5. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  6. 1 1
      packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.ts
  7. 105 163
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  8. 184 0
      packages/core/e2e/fixtures/test-plugins/issue-1636-1664/issue-1636-1664-plugin.ts
  9. 19 0
      packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile-asset.entity.ts
  10. 39 0
      packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile.entity.ts
  11. 51 5
      packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts
  12. 83 1
      packages/core/e2e/list-query-builder.e2e-spec.ts
  13. 8 3
      packages/core/src/connection/remove-custom-fields-with-eager-relations.ts
  14. 56 15
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  15. 1 1
      packages/core/src/service/services/customer.service.ts
  16. 1 1
      packages/core/src/service/services/order.service.ts
  17. 0 109
      packages/dev-server/test-plugins/issue-1664.ts
  18. 114 0
      packages/dev-server/test-plugins/issue-1664/issue-1664.ts
  19. 19 0
      packages/dev-server/test-plugins/issue-1664/profile-asset.entity.ts
  20. 39 0
      packages/dev-server/test-plugins/issue-1664/profile.entity.ts

+ 10 - 0
CHANGELOG.md

@@ -1,3 +1,13 @@
+## <small>1.8.2 (2022-11-01)</small>
+
+
+#### Fixes
+
+* **admin-ui** Display descriptions for custom fields ([aaeb43d](https://github.com/vendure-ecommerce/vendure/commit/aaeb43d))
+* **admin-ui** Fix getting custom fields in Zone dialog ([a919555](https://github.com/vendure-ecommerce/vendure/commit/a919555))
+* **core** Fix edge case with nested eager custom field relations ([ca9848c](https://github.com/vendure-ecommerce/vendure/commit/ca9848c)), closes [#1664](https://github.com/vendure-ecommerce/vendure/issues/1664)
+* **core** Fix nested relations in ListQueryBuilder customPropertyMap ([839fa37](https://github.com/vendure-ecommerce/vendure/commit/839fa37)), closes [#1851](https://github.com/vendure-ecommerce/vendure/issues/1851) [#1774](https://github.com/vendure-ecommerce/vendure/issues/1774)
+
 ## <small>1.8.1 (2022-10-26)</small>
 
 This release corrects a publishing error with the `@vendure/admin-ui` packages. There are no code changes in this release.

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -222,6 +222,7 @@ export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.comp
 export * from './shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component';
 export * from './shared/pipes/asset-preview.pipe';
 export * from './shared/pipes/channel-label.pipe';
+export * from './shared/pipes/custom-field-description.pipe';
 export * from './shared/pipes/custom-field-label.pipe';
 export * from './shared/pipes/duration.pipe';
 export * from './shared/pipes/file-size.pipe';

+ 15 - 2
packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.html

@@ -1,12 +1,25 @@
 <div class="clr-form-control" *ngIf="compact">
-    <label for="basic" class="clr-control-label">{{ customField | customFieldLabel:(uiLanguage$ | async) }}</label>
+    <div class="flex">
+        <label for="basic" class="clr-control-label"
+            >{{ customField | customFieldLabel: (uiLanguage$ | async) }}
+            <vdr-help-tooltip
+                *ngIf="customField | customFieldDescription: (uiLanguage$ | async) as description"
+                [content]="description"
+            ></vdr-help-tooltip>
+        </label>
+    </div>
     <div class="clr-control-container">
         <div class="clr-input-wrapper">
             <ng-container *ngTemplateOutlet="inputs"></ng-container>
         </div>
     </div>
 </div>
-<vdr-form-field [label]="customField | customFieldLabel:(uiLanguage$ | async)" [for]="customField.name" *ngIf="!compact">
+<vdr-form-field
+    [label]="customField | customFieldLabel: (uiLanguage$ | async)"
+    [tooltip]="customField | customFieldDescription: (uiLanguage$ | async)"
+    [for]="customField.name"
+    *ngIf="!compact"
+>
     <ng-container *ngTemplateOutlet="inputs"></ng-container>
 </vdr-form-field>
 

+ 25 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/custom-field-description.pipe.ts

@@ -0,0 +1,25 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { CustomFieldConfig, LanguageCode, StringFieldOption } from '../../common/generated-types';
+
+/**
+ * Displays a localized description for a CustomField
+ */
+@Pipe({
+    name: 'customFieldDescription',
+    pure: true,
+})
+export class CustomFieldDescriptionPipe implements PipeTransform {
+    transform(value: CustomFieldConfig, uiLanguageCode: LanguageCode | null): string {
+        if (!value) {
+            return value;
+        }
+        const { description } = value;
+        if (description) {
+            const match = description.find(l => l.languageCode === uiLanguageCode);
+            return match ? match.value : description[0].value;
+        } else {
+            return '';
+        }
+    }
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -124,6 +124,7 @@ import { TextFormInputComponent } from './dynamic-form-inputs/text-form-input/te
 import { TextareaFormInputComponent } from './dynamic-form-inputs/textarea-form-input/textarea-form-input.component';
 import { AssetPreviewPipe } from './pipes/asset-preview.pipe';
 import { ChannelLabelPipe } from './pipes/channel-label.pipe';
+import { CustomFieldDescriptionPipe } from './pipes/custom-field-description.pipe';
 import { CustomFieldLabelPipe } from './pipes/custom-field-label.pipe';
 import { DurationPipe } from './pipes/duration.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
@@ -215,6 +216,7 @@ const DECLARATIONS = [
     IfDefaultChannelActiveDirective,
     ExtensionHostComponent,
     CustomFieldLabelPipe,
+    CustomFieldDescriptionPipe,
     FocalPointControlComponent,
     AssetPreviewPipe,
     LinkDialogComponent,

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.ts

@@ -16,7 +16,7 @@ export class ZoneDetailDialogComponent implements Dialog<CreateZoneInput>, OnIni
     form: FormGroup;
 
     constructor(private serverConfigService: ServerConfigService, private formBuilder: FormBuilder) {
-        this.customFields = this.serverConfigService.getCustomFieldsFor('CustomerGroup');
+        this.customFields = this.serverConfigService.getCustomFieldsFor('Zone');
     }
 
     ngOnInit() {

+ 105 - 163
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -1,43 +1,32 @@
-import { Args, Query, Resolver } from '@nestjs/graphql';
-import { ID } from '@vendure/common/lib/shared-types';
 import {
     Asset,
     Collection,
     Country,
-    Ctx,
     CustomFields,
+    DefaultLogger,
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
     Facet,
     FacetValue,
+    LogLevel,
     manualFulfillmentHandler,
     mergeConfig,
-    PluginCommonModule,
     Product,
     ProductOption,
     ProductOptionGroup,
     ProductVariant,
-    RequestContext,
     ShippingMethod,
-    TransactionalConnection,
-    VendureEntity,
-    VendurePlugin,
 } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
-import { Entity, JoinColumn, OneToOne } from 'typeorm';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
-import { AddItemToOrderMutation } from './graphql/generated-e2e-shop-types';
-import {
-    UpdateProductVariantsMutation,
-    UpdateProductVariantsMutationVariables,
-} from './graphql/generated-e2e-admin-types';
-import { UPDATE_PRODUCT_VARIANTS } from './graphql/shared-definitions';
+import { TestPlugin1636_1664 } from './fixtures/test-plugins/issue-1636-1664/issue-1636-1664-plugin';
+import { AddItemToOrderMutationVariables } from './graphql/generated-e2e-shop-types';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { sortById } from './utils/test-order-utils';
 
@@ -79,17 +68,6 @@ const entitiesWithCustomFields = enumerate<keyof CustomFields>()(
     'Zone',
 );
 
-@Entity()
-class Vendor extends VendureEntity {
-    constructor() {
-        super();
-    }
-
-    @OneToOne(type => Product, { eager: true })
-    @JoinColumn()
-    featuredProduct: Product;
-}
-
 const customFieldConfig: CustomFields = {};
 for (const entity of entitiesWithCustomFields) {
     customFieldConfig[entity] = [
@@ -108,26 +86,7 @@ customFieldConfig.Product?.push(
     { name: 'cfProduct', type: 'relation', entity: Product, list: false },
     { name: 'cfShippingMethod', type: 'relation', entity: ShippingMethod, list: false },
     { name: 'cfInternalAsset', type: 'relation', entity: Asset, list: false, internal: true },
-    {
-        name: 'cfVendor',
-        type: 'relation',
-        entity: Vendor,
-        graphQLType: 'Vendor',
-        list: false,
-        internal: false,
-        public: true,
-    },
 );
-customFieldConfig.User?.push({
-    name: 'cfVendor',
-    type: 'relation',
-    entity: Vendor,
-    graphQLType: 'Vendor',
-    list: false,
-    eager: true,
-    internal: false,
-    public: true,
-});
 customFieldConfig.ProductVariant?.push({
     name: 'cfRelatedProducts',
     type: 'relation',
@@ -137,49 +96,6 @@ customFieldConfig.ProductVariant?.push({
     public: true,
 });
 
-const testResolverSpy = jest.fn();
-
-@Resolver()
-class TestResolver1636 {
-    constructor(private connection: TransactionalConnection) {}
-
-    @Query()
-    async getAssetTest(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
-        const asset = await this.connection.findOneInChannel(ctx, Asset, args.id, ctx.channelId, {
-            relations: ['customFields.single', 'customFields.multi'],
-        });
-        testResolverSpy(asset);
-        return true;
-    }
-}
-
-@VendurePlugin({
-    imports: [PluginCommonModule],
-    entities: [Vendor],
-    shopApiExtensions: {
-        schema: gql`
-            extend type Query {
-                getAssetTest(id: ID!): Boolean!
-            }
-            type Vendor {
-                id: ID
-                featuredProduct: Product
-            }
-        `,
-        resolvers: [TestResolver1636],
-    },
-    adminApiExtensions: {
-        schema: gql`
-            type Vendor {
-                id: ID
-                featuredProduct: Product
-            }
-        `,
-        resolvers: [],
-    },
-})
-class TestPlugin1636 {}
-
 const customConfig = mergeConfig(testConfig(), {
     paymentOptions: {
         paymentMethodHandlers: [testSuccessfulPaymentMethod],
@@ -189,7 +105,7 @@ const customConfig = mergeConfig(testConfig(), {
         timezone: 'Z',
     },
     customFields: customFieldConfig,
-    plugins: [TestPlugin1636],
+    plugins: [TestPlugin1636_1664],
 });
 
 describe('Custom field relations', () => {
@@ -680,7 +596,7 @@ describe('Custom field relations', () => {
             let orderId: string;
 
             beforeAll(async () => {
-                const { addItemToOrder } = await shopClient.query<any, AddItemToOrder.Variables>(
+                const { addItemToOrder } = await shopClient.query<any, AddItemToOrderMutationVariables>(
                     ADD_ITEM_TO_ORDER,
                     {
                         productVariantId: 'T_1',
@@ -825,104 +741,130 @@ describe('Custom field relations', () => {
                 assertCustomFieldIds(updateProductVariants[0].customFields, 'T_2', ['T_3', 'T_4']);
             });
 
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product by id with eager-loading custom field relation', async () => {
-                const { product } = await shopClient.query(gql`
-                    query {
-                        product(id: "T_1") {
-                            id
-                            customFields {
-                                cfVendor {
-                                    featuredProduct {
-                                        id
+            describe('issue 1664', () => {
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product by id with eager-loading custom field relation', async () => {
+                    const { product } = await shopClient.query(gql`
+                        query {
+                            product(id: "T_1") {
+                                id
+                                customFields {
+                                    cfVendor {
+                                        featuredProduct {
+                                            id
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
-            });
+                    expect(product).toBeDefined();
+                });
 
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product by id with nested eager-loading custom field relation', async () => {
-                const { customer } = await adminClient.query(gql`
-                    query {
-                        customer(id: "T_1") {
-                            id
-                            firstName
-                            lastName
-                            emailAddress
-                            phoneNumber
-                            user {
-                                customFields {
-                                    cfVendor {
-                                        id
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product by id with nested eager-loading custom field relation', async () => {
+                    const { customer } = await adminClient.query(gql`
+                        query {
+                            customer(id: "T_1") {
+                                id
+                                firstName
+                                lastName
+                                emailAddress
+                                phoneNumber
+                                user {
+                                    customFields {
+                                        cfVendor {
+                                            id
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
-
-                expect(customer).toBeDefined();
-            });
-
-            // https://github.com/vendure-ecommerce/vendure/issues/1664
-            it('successfully gets product.variants with nested custom field relation', async () => {
-                await adminClient.query(gql`
-                    mutation {
-                        updateProductVariants(
-                            input: [{ id: "T_1", customFields: { cfRelatedProductsIds: ["T_2"] } }]
-                        ) {
-                            id
+                    `);
+
+                    expect(customer).toBeDefined();
+                });
+
+                // https://github.com/vendure-ecommerce/vendure/issues/1664
+                it('successfully gets product.variants with nested custom field relation', async () => {
+                    await adminClient.query(gql`
+                        mutation {
+                            updateProductVariants(
+                                input: [{ id: "T_1", customFields: { cfRelatedProductsIds: ["T_2"] } }]
+                            ) {
+                                id
+                            }
                         }
-                    }
-                `);
-
-                const { product } = await adminClient.query(gql`
-                    query {
-                        product(id: "T_1") {
-                            variants {
+                    `);
+
+                    const { product } = await adminClient.query(gql`
+                        query {
+                            product(id: "T_1") {
+                                variants {
+                                    id
+                                    customFields {
+                                        cfRelatedProducts {
+                                            featuredAsset {
+                                                id
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    `);
+
+                    expect(product).toBeDefined();
+                    expect(product.variants[0].customFields.cfRelatedProducts).toEqual([
+                        {
+                            featuredAsset: { id: 'T_2' },
+                        },
+                    ]);
+                });
+
+                it('successfully gets product by slug with eager-loading custom field relation', async () => {
+                    const { product } = await shopClient.query(gql`
+                        query {
+                            product(slug: "laptop") {
                                 id
                                 customFields {
-                                    cfRelatedProducts {
-                                        featuredAsset {
+                                    cfVendor {
+                                        featuredProduct {
                                             id
                                         }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
-                expect(product.variants[0].customFields.cfRelatedProducts).toEqual([
-                    {
-                        featuredAsset: { id: 'T_2' },
-                    },
-                ]);
-            });
+                    expect(product).toBeDefined();
+                });
 
-            it('successfully gets product by slug with eager-loading custom field relation', async () => {
-                const { product } = await shopClient.query(gql`
-                    query {
-                        product(slug: "laptop") {
-                            id
-                            customFields {
-                                cfVendor {
-                                    featuredProduct {
+                it('does not error on custom field relation with eager custom field relation', async () => {
+                    const { product } = await adminClient.query(gql`
+                        query {
+                            product(slug: "laptop") {
+                                name
+                                customFields {
+                                    owner {
                                         id
+                                        code
+                                        customFields {
+                                            profile {
+                                                id
+                                                name
+                                            }
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
-                `);
+                    `);
 
-                expect(product).toBeDefined();
+                    expect(product).toBeDefined();
+                });
             });
         });
 
@@ -1177,13 +1119,13 @@ describe('Custom field relations', () => {
 
             // https://github.com/vendure-ecommerce/vendure/issues/1636
             it('calling TransactionalConnection.findOneInChannel() returns custom field relations', async () => {
-                testResolverSpy.mockReset();
+                TestPlugin1636_1664.testResolverSpy.mockReset();
                 await shopClient.query(gql`
                     query {
                         getAssetTest(id: "T_1")
                     }
                 `);
-                const args = testResolverSpy.mock.calls[0];
+                const args = TestPlugin1636_1664.testResolverSpy.mock.calls[0];
                 expect(args[0].customFields.single.id).toEqual(2);
                 expect(args[0].customFields.multi.length).toEqual(2);
             });

+ 184 - 0
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/issue-1636-1664-plugin.ts

@@ -0,0 +1,184 @@
+import { OnApplicationBootstrap } from '@nestjs/common';
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
+import {
+    Asset,
+    Channel,
+    Ctx,
+    Customer,
+    PluginCommonModule,
+    Product,
+    RequestContext,
+    TransactionalConnection,
+    User,
+    VendureEntity,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+import { Entity, JoinColumn, OneToOne } from 'typeorm';
+
+import { ProfileAsset } from './profile-asset.entity';
+import { Profile } from './profile.entity';
+
+@Entity()
+export class Vendor extends VendureEntity {
+    constructor() {
+        super();
+    }
+
+    @OneToOne(type => Product, { eager: true })
+    @JoinColumn()
+    featuredProduct: Product;
+}
+
+@Resolver()
+class TestResolver1636 {
+    constructor(private connection: TransactionalConnection) {}
+
+    @Query()
+    async getAssetTest(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
+        const asset = await this.connection.findOneInChannel(ctx, Asset, args.id, ctx.channelId, {
+            relations: ['customFields.single', 'customFields.multi'],
+        });
+        TestPlugin1636_1664.testResolverSpy(asset);
+        return true;
+    }
+}
+
+const profileType = gql`
+    type Profile implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        user: User!
+    }
+`;
+
+/**
+ * Testing https://github.com/vendure-ecommerce/vendure/issues/1636
+ *
+ * and
+ *
+ * https://github.com/vendure-ecommerce/vendure/issues/1664
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [Vendor, Profile, ProfileAsset],
+    shopApiExtensions: {
+        schema: gql`
+            extend type Query {
+                getAssetTest(id: ID!): Boolean!
+            }
+            type Vendor {
+                id: ID
+                featuredProduct: Product
+            }
+        `,
+        resolvers: [TestResolver1636],
+    },
+    adminApiExtensions: {
+        schema: gql`
+            type Vendor {
+                id: ID
+                featuredProduct: Product
+            }
+            type Profile implements Node {
+                id: ID!
+                createdAt: DateTime!
+                updatedAt: DateTime!
+                name: String!
+                user: User!
+            }
+        `,
+        resolvers: [],
+    },
+    configuration: config => {
+        config.customFields.Product.push(
+            {
+                name: 'cfVendor',
+                type: 'relation',
+                entity: Vendor,
+                graphQLType: 'Vendor',
+                list: false,
+                internal: false,
+                public: true,
+            },
+            {
+                name: 'owner',
+                nullable: true,
+                type: 'relation',
+                // Using the Channel entity rather than User as in the example comment at
+                // https://github.com/vendure-ecommerce/vendure/issues/1664#issuecomment-1293916504
+                // because using a User causes a recursive infinite loop in TypeORM between
+                // Product > User > Vendor > Product etc.
+                entity: Channel,
+                public: false,
+                eager: true, // needs to be eager to enable indexing of user->profile attributes like name, etc.
+                readonly: true,
+            },
+        );
+        config.customFields.User.push({
+            name: 'cfVendor',
+            type: 'relation',
+            entity: Vendor,
+            graphQLType: 'Vendor',
+            list: false,
+            eager: true,
+            internal: false,
+            public: true,
+        });
+
+        config.customFields.Channel.push({
+            name: 'profile',
+            type: 'relation',
+            entity: Profile,
+            nullable: true,
+            public: false,
+            internal: false,
+            readonly: true,
+            eager: true, // needs to be eager to enable indexing of profile attributes like name, etc.
+        });
+
+        return config;
+    },
+})
+// tslint:disable-next-line:class-name
+export class TestPlugin1636_1664 implements OnApplicationBootstrap {
+    static testResolverSpy = jest.fn();
+
+    constructor(private connection: TransactionalConnection) {}
+
+    async onApplicationBootstrap() {
+        const profilesCount = await this.connection.rawConnection.getRepository(Profile).count();
+        if (0 < profilesCount) {
+            return;
+        }
+        // Create a Profile and assign it to all the products
+        const channels = await this.connection.rawConnection.getRepository(Channel).find();
+        // tslint:disable-next-line:no-non-null-assertion
+        const channel = channels[0]!;
+        const profile = await this.connection.rawConnection.getRepository(Profile).save(
+            new Profile({
+                name: 'Test Profile',
+            }),
+        );
+
+        (channel.customFields as any).profile = profile;
+        await this.connection.rawConnection.getRepository(Channel).save(channel);
+
+        const asset = await this.connection.rawConnection.getRepository(Asset).findOne(1);
+        if (asset) {
+            const profileAsset = this.connection.rawConnection.getRepository(ProfileAsset).save({
+                asset,
+                profile,
+            });
+        }
+
+        const products = await this.connection.rawConnection.getRepository(Product).find();
+        for (const product of products) {
+            (product.customFields as any).owner = channel;
+            await this.connection.rawConnection.getRepository(Product).save(product);
+        }
+    }
+}

+ 19 - 0
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile-asset.entity.ts

@@ -0,0 +1,19 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Asset, VendureEntity } from '@vendure/core';
+import { Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
+
+import { Profile } from './profile.entity';
+
+@Entity()
+export class ProfileAsset extends VendureEntity {
+    constructor(input?: DeepPartial<ProfileAsset>) {
+        super(input);
+    }
+
+    @OneToOne(() => Asset, { eager: true, onDelete: 'CASCADE' })
+    @JoinColumn()
+    asset: Asset;
+
+    @ManyToOne(() => Profile, { onDelete: 'CASCADE' })
+    profile: Profile;
+}

+ 39 - 0
packages/core/e2e/fixtures/test-plugins/issue-1636-1664/profile.entity.ts

@@ -0,0 +1,39 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { User, VendureEntity } from '@vendure/core';
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { ProfileAsset } from './profile-asset.entity';
+
+@Entity()
+export class Profile extends VendureEntity {
+    constructor(input?: DeepPartial<Profile>) {
+        super(input);
+    }
+    /**
+     * The reference to a user
+     */
+    @ManyToOne(() => User, user => (user as any).profileId, { onDelete: 'CASCADE' })
+    user: User;
+    /**
+     * Profile display name
+     */
+    @Column()
+    name: string;
+    /**
+     * The profile picture
+     */
+    @OneToOne(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'SET NULL',
+        nullable: true,
+    })
+    @JoinColumn()
+    featuredAsset: ProfileAsset;
+
+    /**
+     * Other assets
+     */
+    @OneToMany(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'CASCADE',
+    })
+    assets: ProfileAsset[];
+}

+ 51 - 5
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -1,13 +1,18 @@
-import { OnApplicationBootstrap } from '@nestjs/common';
+import { Logger, OnApplicationBootstrap } from '@nestjs/common';
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import {
     Ctx,
+    Customer,
+    CustomerService,
     ListQueryBuilder,
     LocaleString,
+    Order,
+    OrderService,
     PluginCommonModule,
     RequestContext,
+    RequestContextService,
     TransactionalConnection,
     Translatable,
     translateDeep,
@@ -16,10 +21,9 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../../src/common/calculated-decorator';
-import { EntityId } from '../../../src/entity/entity-id.decorator';
 
 @Entity()
 export class TestEntity extends VendureEntity implements Translatable {
@@ -83,6 +87,10 @@ export class TestEntity extends VendureEntity implements Translatable {
 
     @OneToMany(type => TestEntityTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<TestEntity>>;
+
+    @OneToOne(type => Order)
+    @JoinColumn()
+    orderRelation: Order;
 }
 
 @Entity()
@@ -122,7 +130,13 @@ export class ListQueryResolver {
     @Query()
     testEntities(@Ctx() ctx: RequestContext, @Args() args: any) {
         return this.listQueryBuilder
-            .build(TestEntity, args.options, { ctx })
+            .build(TestEntity, args.options, {
+                ctx,
+                relations: ['orderRelation', 'orderRelation.customer'],
+                customPropertyMap: {
+                    customerLastName: 'orderRelation.customer.lastName',
+                },
+            })
             .getManyAndCount()
             .then(([items, totalItems]) => {
                 for (const item of items) {
@@ -178,6 +192,7 @@ const apiExtensions = gql`
         price: Int!
         ownerId: ID!
         translations: [TestEntityTranslation!]!
+        orderRelation: Order
     }
 
     type TestEntityList implements PaginatedList {
@@ -190,6 +205,14 @@ const apiExtensions = gql`
         testEntitiesGetMany(options: TestEntityListOptions): [TestEntity!]!
     }
 
+    input TestEntityFilterParameter {
+        customerLastName: StringOperators
+    }
+
+    input TestEntitySortParameter {
+        customerLastName: SortOrder
+    }
+
     input TestEntityListOptions
 `;
 
@@ -206,7 +229,12 @@ const apiExtensions = gql`
     },
 })
 export class ListQueryPlugin implements OnApplicationBootstrap {
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private requestContextService: RequestContextService,
+        private customerService: CustomerService,
+        private orderService: OrderService,
+    ) {}
 
     async onApplicationBootstrap() {
         const count = await this.connection.getRepository(TestEntity).count();
@@ -298,6 +326,24 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                     }
                 }
             }
+        } else {
+            const testEntities = await this.connection.getRepository(TestEntity).find();
+            const ctx = await this.requestContextService.create({ apiType: 'admin' });
+            const customers = await this.connection.rawConnection.getRepository(Customer).find();
+            let i = 0;
+
+            for (const testEntity of testEntities) {
+                const customer = customers[i % customers.length];
+                try {
+                    // tslint:disable-next-line:no-non-null-assertion
+                    const order = await this.orderService.create(ctx, customer.user!.id);
+                    testEntity.orderRelation = order;
+                    await this.connection.getRepository(TestEntity).save(testEntity);
+                } catch (e: any) {
+                    Logger.error(e);
+                }
+                i++;
+            }
         }
     }
 }

+ 83 - 1
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -29,7 +29,7 @@ describe('ListQueryBuilder', () => {
         await server.init({
             initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-            customerCount: 1,
+            customerCount: 3,
         });
         await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
@@ -975,6 +975,7 @@ describe('ListQueryBuilder', () => {
                 { languageCode: LanguageCode.de, name: 'fahrrad' },
             ],
         ];
+
         function getTestEntityTranslations(testEntities: { items: any[] }) {
             // Explicitly sort the order of the translations as it was being non-deterministic on
             // the mysql CI tests.
@@ -1016,6 +1017,67 @@ describe('ListQueryBuilder', () => {
             expect(testEntityTranslations).toEqual(allTranslations);
         });
     });
+
+    describe('customPropertyMap', () => {
+        it('filter by custom string field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_ORDERS, {
+                options: {
+                    sort: {
+                        label: SortOrder.ASC,
+                    },
+                    filter: {
+                        customerLastName: { contains: 'zieme' },
+                    },
+                },
+            });
+
+            expect(testEntities.items).toEqual([
+                {
+                    id: 'T_1',
+                    label: 'A',
+                    name: 'apple',
+                    orderRelation: {
+                        customer: {
+                            firstName: 'Hayden',
+                            lastName: 'Zieme',
+                        },
+                        id: 'T_1',
+                    },
+                },
+                {
+                    id: 'T_4',
+                    label: 'D',
+                    name: 'dog',
+                    orderRelation: {
+                        customer: {
+                            firstName: 'Hayden',
+                            lastName: 'Zieme',
+                        },
+                        id: 'T_4',
+                    },
+                },
+            ]);
+        });
+
+        it('sort by custom string field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_ORDERS, {
+                options: {
+                    sort: {
+                        customerLastName: SortOrder.ASC,
+                    },
+                },
+            });
+
+            expect(testEntities.items.map((i: any) => i.orderRelation.customer)).toEqual([
+                { firstName: 'Trevor', lastName: 'Donnelly' },
+                { firstName: 'Trevor', lastName: 'Donnelly' },
+                { firstName: 'Marques', lastName: 'Sawayn' },
+                { firstName: 'Marques', lastName: 'Sawayn' },
+                { firstName: 'Hayden', lastName: 'Zieme' },
+                { firstName: 'Hayden', lastName: 'Zieme' },
+            ]);
+        });
+    });
 });
 
 const GET_LIST = gql`
@@ -1050,6 +1112,26 @@ const GET_LIST_WITH_TRANSLATIONS = gql`
     }
 `;
 
+const GET_LIST_WITH_ORDERS = gql`
+    query GetTestEntitiesWithTranslations($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            totalItems
+            items {
+                id
+                label
+                name
+                orderRelation {
+                    id
+                    customer {
+                        firstName
+                        lastName
+                    }
+                }
+            }
+        }
+    }
+`;
+
 const GET_ARRAY_LIST = gql`
     query GetTestEntitiesArray($options: TestEntityListOptions) {
         testEntitiesGetMany(options: $options) {

+ 8 - 3
packages/core/src/connection/remove-custom-fields-with-eager-relations.ts

@@ -45,9 +45,14 @@ export function removeCustomFieldsWithEagerRelations(
         metadata => metadata.propertyName === 'customFields',
     );
     if (customFieldsMetadata) {
-        const customFieldRelationsWithEagerRelations = customFieldsMetadata.relations.filter(
-            relation => !!relation.inverseEntityMetadata.ownRelations.find(or => or.isEager === true),
-        );
+        const customFieldRelationsWithEagerRelations = customFieldsMetadata.relations.filter(relation => {
+            return (
+                !!relation.inverseEntityMetadata.ownRelations.find(or => or.isEager === true) ||
+                relation.inverseEntityMetadata.embeddeds.find(
+                    em => em.propertyName === 'customFields' && em.relations.find(emr => emr.isEager),
+                )
+            );
+        });
         for (const relation of customFieldRelationsWithEagerRelations) {
             const propertyName = relation.propertyName;
             const relationsToRemove = relations.filter(r => r.startsWith(`customFields.${propertyName}`));

+ 56 - 15
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -90,15 +90,26 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
      *   customPropertyMap: {
      *     // Tell TypeORM how to map that custom
      *     // sort/filter field to the property on a
-     *     // related entity. Note that the `customer`
-     *     // part needs to match the *table name* of the
-     *     // related entity. So, e.g. if you are mapping to
-     *     // a `FacetValue` relation's `id` property, the value
-     *     // would be `facet_value.id`.
+     *     // related entity.
      *     customerLastName: 'customer.lastName',
      *   },
      * };
      * ```
+     * We can now use the `customerLastName` property to filter or sort
+     * on the list query:
+     *
+     * @example
+     * ```GraphQL
+     * query {
+     *   myOrderQuery(options: {
+     *     filter: {
+     *       customerLastName: { contains: "sm" }
+     *     }
+     *   }) {
+     *     # ...
+     *   }
+     * }
+     * ```
      */
     customPropertyMap?: { [name: string]: string };
 };
@@ -322,14 +333,41 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     // to join the associated relations.
                     continue;
                 }
-                const tableNameLower = path.split('.')[0];
-                const entityMetadata = repository.manager.connection.entityMetadatas.find(
-                    em => em.tableNameWithoutPrefix === tableNameLower,
-                );
-                if (entityMetadata) {
-                    const relationMetadata = metadata.relations.find(r => r.type === entityMetadata.target);
+
+                // TODO: Delete for v2
+                // This is a work-around to allow the use of the legacy table-name-based
+                // customPropertyMap syntax
+                let relationPathYieldedMatch = false;
+
+                const relationPath = path.split('.').slice(0, -1);
+                let targetMetadata = metadata;
+                const recontructedPath = [];
+                for (const relationPathPart of relationPath) {
+                    const relationMetadata = targetMetadata.findRelationWithPropertyPath(relationPathPart);
                     if (relationMetadata) {
-                        requiredRelations.push(relationMetadata.propertyName);
+                        recontructedPath.push(relationMetadata.propertyName);
+                        requiredRelations.push(recontructedPath.join('.'));
+                        targetMetadata = relationMetadata.inverseEntityMetadata;
+                        relationPathYieldedMatch = true;
+                    }
+                }
+
+                if (!relationPathYieldedMatch) {
+                    // TODO: Delete this in v2.
+                    // Legacy behaviour that uses the table name to reference relations.
+                    // This causes a bunch of issues and is also a bad, unintuitive way to
+                    // reference relations. See https://github.com/vendure-ecommerce/vendure/issues/1774
+                    const tableNameLower = path.split('.')[0];
+                    const entityMetadata = repository.manager.connection.entityMetadatas.find(
+                        em => em.tableNameWithoutPrefix === tableNameLower,
+                    );
+                    if (entityMetadata) {
+                        const relationMetadata = metadata.relations.find(
+                            r => r.type === entityMetadata.target,
+                        );
+                        if (relationMetadata) {
+                            requiredRelations.push(relationMetadata.propertyName);
+                        }
                     }
                 }
             }
@@ -478,9 +516,12 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             const parts = customPropertyMap[property].split('.');
             const entityPart = 2 <= parts.length ? parts[parts.length - 2] : qb.alias;
             const columnPart = parts[parts.length - 1];
-            const relationAlias = qb.expressionMap.aliases.find(
-                a => a.metadata.tableNameWithoutPrefix === entityPart,
-            );
+
+            const relationMetadata =
+                qb.expressionMap.mainAlias?.metadata.findRelationWithPropertyPath(entityPart);
+            const relationAlias =
+                qb.expressionMap.aliases.find(a => a.metadata.tableNameWithoutPrefix === entityPart) ??
+                qb.expressionMap.joinAttributes.find(ja => ja.relationCache === relationMetadata)?.alias;
             if (relationAlias) {
                 customPropertyMap[property] = `${relationAlias.name}.${columnPart}`;
             } else {

+ 1 - 1
packages/core/src/service/services/customer.service.ts

@@ -99,7 +99,7 @@ export class CustomerService {
         const hasPostalCodeFilter = !!(options as CustomerListOptions)?.filter?.postalCode;
         if (hasPostalCodeFilter) {
             relations.push('addresses');
-            customPropertyMap.postalCode = 'address.postalCode';
+            customPropertyMap.postalCode = 'addresses.postalCode';
         }
         return this.listQueryBuilder
             .build(Customer, options, {

+ 1 - 1
packages/core/src/service/services/order.service.ts

@@ -201,7 +201,7 @@ export class OrderService {
                 channelId: ctx.channelId,
                 customPropertyMap: {
                     customerLastName: 'customer.lastName',
-                    transactionId: 'payment.transactionId',
+                    transactionId: 'payments.transactionId',
                 },
             })
             .getManyAndCount()

+ 0 - 109
packages/dev-server/test-plugins/issue-1664.ts

@@ -1,109 +0,0 @@
-import {
-    Facet,
-    LanguageCode,
-    LocaleString,
-    PluginCommonModule,
-    Product,
-    Translation,
-    VendureEntity,
-    VendurePlugin,
-} from '@vendure/core';
-import gql from 'graphql-tag';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
-
-@Entity()
-class Vendor extends VendureEntity {
-    constructor(input: Partial<Vendor>) {
-        super(input);
-    }
-
-    description: LocaleString;
-
-    @Column()
-    name: string;
-
-    @OneToMany(() => Product, product => (product.customFields as any).vendor)
-    products: Product[];
-
-    @OneToMany(() => VendorTranslation, translation => translation.base, { eager: true })
-    translations: Array<Translation<Vendor>>;
-}
-
-@Entity()
-export class VendorTranslation extends VendureEntity implements Translation<Vendor> {
-    constructor(input?: Partial<Translation<Vendor>>) {
-        super(input);
-    }
-
-    @Column('varchar')
-    languageCode: LanguageCode;
-
-    @Column('text')
-    description: string;
-
-    @ManyToOne(() => Vendor, vendor => vendor.translations, { onDelete: 'CASCADE' })
-    base: Vendor;
-}
-
-const schema = gql`
-    type Vendor implements Node {
-        id: ID!
-        createdAt: DateTime!
-        updatedAt: DateTime!
-        name: String!
-        description: String!
-    }
-`;
-
-/**
- * Test plugin for https://github.com/vendure-ecommerce/vendure/issues/1664
- */
-@VendurePlugin({
-    imports: [PluginCommonModule],
-    entities: [Vendor, VendorTranslation],
-    shopApiExtensions: { schema, resolvers: [] },
-    adminApiExtensions: { schema, resolvers: [] },
-    configuration: config => {
-        config.customFields.Product.push({
-            name: 'vendor',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
-            type: 'relation',
-            entity: Vendor,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-            ui: {
-                component: 'cp-product-vendor-selector',
-            },
-        });
-        config.customFields.User.push({
-            name: 'vendor',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
-            type: 'relation',
-            entity: Vendor,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-            ui: {
-                component: 'cp-product-vendor-selector',
-            },
-        });
-        config.customFields.User.push({
-            name: 'facet',
-            label: [{ languageCode: LanguageCode.en_AU, value: 'Facet' }],
-            type: 'relation',
-            entity: Facet,
-            eager: true,
-            nullable: false,
-            defaultValue: null,
-        });
-
-        config.customFields.Product.push({
-            name: 'shopifyId',
-            type: 'float',
-            public: false,
-        });
-        return config;
-    },
-})
-export class Test1664Plugin {}

+ 114 - 0
packages/dev-server/test-plugins/issue-1664/issue-1664.ts

@@ -0,0 +1,114 @@
+import { OnApplicationBootstrap } from '@nestjs/common';
+import {
+    Asset,
+    PluginCommonModule,
+    Product,
+    TransactionalConnection,
+    User,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+
+import { ProfileAsset } from './profile-asset.entity';
+import { Profile } from './profile.entity';
+
+const schema = gql`
+    type Profile implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        user: User!
+    }
+`;
+
+/**
+ * Test plugin for https://github.com/vendure-ecommerce/vendure/issues/1664
+ *
+ * Test query:
+ * ```graphql
+ * query {
+ *   product(id: 1) {
+ *     name
+ *     customFields {
+ *       owner {
+ *         id
+ *         identifier
+ *         customFields {
+ *           profile {
+ *             id
+ *             name
+ *           }
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: () => [Profile, ProfileAsset],
+    shopApiExtensions: { schema, resolvers: [] },
+    adminApiExtensions: { schema, resolvers: [] },
+    configuration: config => {
+        config.customFields.Product.push({
+            name: 'owner',
+            nullable: true,
+            type: 'relation',
+            entity: User,
+            public: false,
+            eager: true, // needs to be eager to enable indexing of user->profile attributes like name, etc.
+            readonly: true,
+        });
+        // User
+        config.customFields.User.push({
+            name: 'profile',
+            type: 'relation',
+            entity: Profile,
+            nullable: true,
+            public: false,
+            internal: false,
+            readonly: true,
+            eager: true, // needs to be eager to enable indexing of profile attributes like name, etc.
+        });
+        return config;
+    },
+})
+export class Test1664Plugin implements OnApplicationBootstrap {
+    constructor(private connection: TransactionalConnection) {}
+
+    async onApplicationBootstrap() {
+        const profilesCount = await this.connection.rawConnection.getRepository(Profile).count();
+        if (0 < profilesCount) {
+            return;
+        }
+        // Create a Profile and assign it to all the products
+        const users = await this.connection.rawConnection.getRepository(User).find();
+        // tslint:disable-next-line:no-non-null-assertion
+        const user = users[1]!;
+        const profile = await this.connection.rawConnection.getRepository(Profile).save(
+            new Profile({
+                name: 'Test Profile',
+                user,
+            }),
+        );
+
+        (user.customFields as any).profile = profile;
+        await this.connection.rawConnection.getRepository(User).save(user);
+
+        const asset = await this.connection.rawConnection.getRepository(Asset).findOne(1);
+        if (asset) {
+            const profileAsset = this.connection.rawConnection.getRepository(ProfileAsset).save({
+                asset,
+                profile,
+            });
+        }
+
+        const products = await this.connection.rawConnection.getRepository(Product).find();
+        for (const product of products) {
+            (product.customFields as any).owner = user;
+            await this.connection.rawConnection.getRepository(Product).save(product);
+        }
+    }
+}

+ 19 - 0
packages/dev-server/test-plugins/issue-1664/profile-asset.entity.ts

@@ -0,0 +1,19 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Asset, VendureEntity } from '@vendure/core';
+import { Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm';
+
+import { Profile } from './profile.entity';
+
+@Entity()
+export class ProfileAsset extends VendureEntity {
+    constructor(input?: DeepPartial<ProfileAsset>) {
+        super(input);
+    }
+
+    @OneToOne(() => Asset, { eager: true, onDelete: 'CASCADE' })
+    @JoinColumn()
+    asset: Asset;
+
+    @ManyToOne(() => Profile, { onDelete: 'CASCADE' })
+    profile: Profile;
+}

+ 39 - 0
packages/dev-server/test-plugins/issue-1664/profile.entity.ts

@@ -0,0 +1,39 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { User, VendureEntity } from '@vendure/core';
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { ProfileAsset } from './profile-asset.entity';
+
+@Entity()
+export class Profile extends VendureEntity {
+    constructor(input?: DeepPartial<Profile>) {
+        super(input);
+    }
+    /**
+     * The reference to a user
+     */
+    @ManyToOne(() => User, user => (user as any).profileId, { onDelete: 'CASCADE' })
+    user: User;
+    /**
+     * Profile display name
+     */
+    @Column()
+    name: string;
+    /**
+     * The profile picture
+     */
+    @OneToOne(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'SET NULL',
+        nullable: true,
+    })
+    @JoinColumn()
+    featuredAsset: ProfileAsset;
+
+    /**
+     * Other assets
+     */
+    @OneToMany(() => ProfileAsset, profileAsset => profileAsset.profile, {
+        onDelete: 'CASCADE',
+    })
+    assets: ProfileAsset[];
+}