Browse Source

feat(admin-ui): Add support for "relation" custom field type

Relates to #308, Relates to #464
Michael Bromley 5 years ago
parent
commit
63e97c7432
53 changed files with 1164 additions and 120 deletions
  1. 14 14
      packages/admin-ui/i18n-coverage.json
  2. 148 49
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  3. 2 0
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  4. 2 0
      packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts
  5. 53 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  6. 12 0
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  7. 8 3
      packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts
  8. 21 0
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  9. 16 1
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  10. 4 5
      packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts
  11. 91 0
      packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.spec.ts
  12. 47 0
      packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.ts
  13. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html
  14. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts
  15. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts
  16. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  17. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  18. 23 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.html
  19. 14 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.scss
  20. 71 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts
  21. 30 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.html
  22. 0 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.scss
  23. 74 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.ts
  24. 37 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.html
  25. 3 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.scss
  26. 95 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.ts
  27. 28 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.html
  28. 3 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.scss
  29. 95 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.ts
  30. 31 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.html
  31. 23 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.scss
  32. 39 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.ts
  33. 29 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.html
  34. 5 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.scss
  35. 19 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.ts
  36. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.html
  37. 0 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.scss
  38. 15 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.ts
  39. 26 1
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  40. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  41. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  42. 6 3
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  43. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  44. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  45. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  46. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  47. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  48. 7 4
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  49. 2 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  50. 8 7
      packages/common/src/generated-types.ts
  51. 2 1
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  52. 2 1
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  53. 0 0
      schema-admin.json

+ 14 - 14
packages/admin-ui/i18n-coverage.json

@@ -1,51 +1,51 @@
 {
-  "generatedOn": "2021-01-19T12:13:56.694Z",
-  "lastCommit": "43a930b617662716c4b02b322289c6c0b1024489",
+  "generatedOn": "2021-01-22T13:12:13.937Z",
+  "lastCommit": "d969e15cbac79672127aee5446532e3e91eb20c5",
   "translationStatus": {
     "cs": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 754,
       "percentage": 99
     },
     "de": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 595,
       "percentage": 78
     },
     "en": {
-      "tokenCount": 760,
-      "translatedCount": 758,
+      "tokenCount": 765,
+      "translatedCount": 764,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 457,
       "percentage": 60
     },
     "fr": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 691,
-      "percentage": 91
+      "percentage": 90
     },
     "pl": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 550,
       "percentage": 72
     },
     "pt_BR": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 641,
       "percentage": 84
     },
     "zh_Hans": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 532,
       "percentage": 70
     },
     "zh_Hant": {
-      "tokenCount": 760,
+      "tokenCount": 765,
       "translatedCount": 532,
       "percentage": 70
     }
   }
-}
+}

+ 148 - 49
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -59,6 +59,9 @@ export type Query = {
   productOptionGroups: Array<ProductOptionGroup>;
   /** Get a ProductVariant by id */
   productVariant?: Maybe<ProductVariant>;
+  /** List ProductVariants */
+  productVariants: ProductVariantList;
+  /** List Products */
   products: ProductList;
   promotion?: Maybe<Promotion>;
   promotionActions: Array<ConfigurableOperationDefinition>;
@@ -218,6 +221,11 @@ export type QueryProductVariantArgs = {
 };
 
 
+export type QueryProductVariantsArgs = {
+  options?: Maybe<ProductVariantListOptions>;
+};
+
+
 export type QueryProductsArgs = {
   options?: Maybe<ProductListOptions>;
 };
@@ -1457,7 +1465,7 @@ export type ImportInfo = {
 /**
  * @description
  * The state of a Job in the JobQueue
- * 
+ *
  * @docsCategory common
  */
 export enum JobState {
@@ -2535,7 +2543,7 @@ export enum DeletionResult {
  * @description
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
- * 
+ *
  * @docsCategory common
  */
 export enum Permission {
@@ -2910,7 +2918,7 @@ export type CountryList = PaginatedList & {
 /**
  * @description
  * ISO 4217 currency code
- * 
+ *
  * @docsCategory common
  */
 export enum CurrencyCode {
@@ -3339,6 +3347,8 @@ export type RelationCustomFieldConfig = CustomField & {
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
   internal?: Maybe<Scalars['Boolean']>;
+  entity: Scalars['String'];
+  scalarFields: Array<Scalars['String']>;
 };
 
 export type LocalizedString = {
@@ -3445,7 +3455,7 @@ export type HistoryEntryList = PaginatedList & {
  * 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 {
@@ -3834,7 +3844,7 @@ export type OrderItem = Node & {
   unitPriceWithTax: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.
-   * 
+   *
    * If Order-level discounts have been applied, this will not be the
    * actual taxable unit price (see `proratedUnitPrice`), but is generally the
    * correct price to display to customers to avoid confusion
@@ -3874,7 +3884,7 @@ export type OrderLine = Node & {
   unitPriceWithTax: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.
-   * 
+   *
    * If Order-level discounts have been applied, this will not be the
    * actual taxable unit price (see `proratedUnitPrice`), but is generally the
    * correct price to display to customers to avoid confusion
@@ -4293,6 +4303,13 @@ export type ProductListOptions = {
   filter?: Maybe<ProductFilterParameter>;
 };
 
+export type ProductVariantListOptions = {
+  skip?: Maybe<Scalars['Int']>;
+  take?: Maybe<Scalars['Int']>;
+  sort?: Maybe<ProductVariantSortParameter>;
+  filter?: Maybe<ProductVariantFilterParameter>;
+};
+
 export type PromotionListOptions = {
   skip?: Maybe<Scalars['Int']>;
   take?: Maybe<Scalars['Int']>;
@@ -4328,13 +4345,6 @@ export type TaxRateListOptions = {
   filter?: Maybe<TaxRateFilterParameter>;
 };
 
-export type ProductVariantListOptions = {
-  skip?: Maybe<Scalars['Int']>;
-  take?: Maybe<Scalars['Int']>;
-  sort?: Maybe<ProductVariantSortParameter>;
-  filter?: Maybe<ProductVariantFilterParameter>;
-};
-
 export type HistoryEntryListOptions = {
   skip?: Maybe<Scalars['Int']>;
   take?: Maybe<Scalars['Int']>;
@@ -4561,6 +4571,38 @@ export type ProductSortParameter = {
   description?: Maybe<SortOrder>;
 };
 
+export type ProductVariantFilterParameter = {
+  enabled?: Maybe<BooleanOperators>;
+  trackInventory?: Maybe<StringOperators>;
+  stockOnHand?: Maybe<NumberOperators>;
+  stockAllocated?: Maybe<NumberOperators>;
+  outOfStockThreshold?: Maybe<NumberOperators>;
+  useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
+  createdAt?: Maybe<DateOperators>;
+  updatedAt?: Maybe<DateOperators>;
+  languageCode?: Maybe<StringOperators>;
+  sku?: Maybe<StringOperators>;
+  name?: Maybe<StringOperators>;
+  price?: Maybe<NumberOperators>;
+  currencyCode?: Maybe<StringOperators>;
+  priceIncludesTax?: Maybe<BooleanOperators>;
+  priceWithTax?: Maybe<NumberOperators>;
+};
+
+export type ProductVariantSortParameter = {
+  stockOnHand?: Maybe<SortOrder>;
+  stockAllocated?: Maybe<SortOrder>;
+  outOfStockThreshold?: Maybe<SortOrder>;
+  id?: Maybe<SortOrder>;
+  productId?: Maybe<SortOrder>;
+  createdAt?: Maybe<SortOrder>;
+  updatedAt?: Maybe<SortOrder>;
+  sku?: Maybe<SortOrder>;
+  name?: Maybe<SortOrder>;
+  price?: Maybe<SortOrder>;
+  priceWithTax?: Maybe<SortOrder>;
+};
+
 export type PromotionFilterParameter = {
   createdAt?: Maybe<DateOperators>;
   updatedAt?: Maybe<DateOperators>;
@@ -4646,38 +4688,6 @@ export type TaxRateSortParameter = {
   value?: Maybe<SortOrder>;
 };
 
-export type ProductVariantFilterParameter = {
-  enabled?: Maybe<BooleanOperators>;
-  trackInventory?: Maybe<StringOperators>;
-  stockOnHand?: Maybe<NumberOperators>;
-  stockAllocated?: Maybe<NumberOperators>;
-  outOfStockThreshold?: Maybe<NumberOperators>;
-  useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
-  createdAt?: Maybe<DateOperators>;
-  updatedAt?: Maybe<DateOperators>;
-  languageCode?: Maybe<StringOperators>;
-  sku?: Maybe<StringOperators>;
-  name?: Maybe<StringOperators>;
-  price?: Maybe<NumberOperators>;
-  currencyCode?: Maybe<StringOperators>;
-  priceIncludesTax?: Maybe<BooleanOperators>;
-  priceWithTax?: Maybe<NumberOperators>;
-};
-
-export type ProductVariantSortParameter = {
-  stockOnHand?: Maybe<SortOrder>;
-  stockAllocated?: Maybe<SortOrder>;
-  outOfStockThreshold?: Maybe<SortOrder>;
-  id?: Maybe<SortOrder>;
-  productId?: Maybe<SortOrder>;
-  createdAt?: Maybe<SortOrder>;
-  updatedAt?: Maybe<SortOrder>;
-  sku?: Maybe<SortOrder>;
-  name?: Maybe<SortOrder>;
-  price?: Maybe<SortOrder>;
-  priceWithTax?: Maybe<SortOrder>;
-};
-
 export type HistoryEntryFilterParameter = {
   isPublic?: Maybe<BooleanOperators>;
   createdAt?: Maybe<DateOperators>;
@@ -6223,6 +6233,20 @@ export type GetProductWithVariantsQuery = { product?: Maybe<(
     & ProductWithVariantsFragment
   )> };
 
+export type GetProductSimpleQueryVariables = Exact<{
+  id: Scalars['ID'];
+}>;
+
+
+export type GetProductSimpleQuery = { product?: Maybe<(
+    { __typename?: 'Product' }
+    & Pick<Product, 'id' | 'name'>
+    & { featuredAsset?: Maybe<(
+      { __typename?: 'Asset' }
+      & AssetFragment
+    )> }
+  )> };
+
 export type GetProductListQueryVariables = Exact<{
   options?: Maybe<ProductListOptions>;
 }>;
@@ -6506,7 +6530,14 @@ export type GetProductVariantQueryVariables = Exact<{
 export type GetProductVariantQuery = { productVariant?: Maybe<(
     { __typename?: 'ProductVariant' }
     & Pick<ProductVariant, 'id' | 'name' | 'sku'>
-    & { product: (
+    & { featuredAsset?: Maybe<(
+      { __typename?: 'Asset' }
+      & Pick<Asset, 'id' | 'preview'>
+      & { focalPoint?: Maybe<(
+        { __typename?: 'Coordinate' }
+        & Pick<Coordinate, 'x' | 'y'>
+      )> }
+    )>, product: (
       { __typename?: 'Product' }
       & Pick<Product, 'id'>
       & { featuredAsset?: Maybe<(
@@ -6520,6 +6551,39 @@ export type GetProductVariantQuery = { productVariant?: Maybe<(
     ) }
   )> };
 
+export type GetProductVariantListQueryVariables = Exact<{
+  options: ProductVariantListOptions;
+}>;
+
+
+export type GetProductVariantListQuery = { productVariants: (
+    { __typename?: 'ProductVariantList' }
+    & Pick<ProductVariantList, 'totalItems'>
+    & { items: Array<(
+      { __typename?: 'ProductVariant' }
+      & Pick<ProductVariant, 'id' | 'name' | 'sku'>
+      & { featuredAsset?: Maybe<(
+        { __typename?: 'Asset' }
+        & Pick<Asset, 'id' | 'preview'>
+        & { focalPoint?: Maybe<(
+          { __typename?: 'Coordinate' }
+          & Pick<Coordinate, 'x' | 'y'>
+        )> }
+      )>, product: (
+        { __typename?: 'Product' }
+        & Pick<Product, 'id'>
+        & { featuredAsset?: Maybe<(
+          { __typename?: 'Asset' }
+          & Pick<Asset, 'id' | 'preview'>
+          & { focalPoint?: Maybe<(
+            { __typename?: 'Coordinate' }
+            & Pick<Coordinate, 'x' | 'y'>
+          )> }
+        )> }
+      ) }
+    )> }
+  ) };
+
 export type GetTagListQueryVariables = Exact<{
   options?: Maybe<TagListOptions>;
 }>;
@@ -7218,6 +7282,12 @@ export type DateTimeCustomFieldFragment = (
   & CustomFieldConfig_DateTimeCustomFieldConfig_Fragment
 );
 
+export type RelationCustomFieldFragment = (
+  { __typename?: 'RelationCustomFieldConfig' }
+  & Pick<RelationCustomFieldConfig, 'entity' | 'scalarFields'>
+  & CustomFieldConfig_RelationCustomFieldConfig_Fragment
+);
+
 type CustomFields_StringCustomFieldConfig_Fragment = (
   { __typename?: 'StringCustomFieldConfig' }
   & StringCustomFieldFragment
@@ -7248,7 +7318,10 @@ type CustomFields_DateTimeCustomFieldConfig_Fragment = (
   & DateTimeCustomFieldFragment
 );
 
-type CustomFields_RelationCustomFieldConfig_Fragment = { __typename?: 'RelationCustomFieldConfig' };
+type CustomFields_RelationCustomFieldConfig_Fragment = (
+  { __typename?: 'RelationCustomFieldConfig' }
+  & RelationCustomFieldFragment
+);
 
 export type CustomFieldsFragment = CustomFields_StringCustomFieldConfig_Fragment | CustomFields_LocaleStringCustomFieldConfig_Fragment | CustomFields_IntCustomFieldConfig_Fragment | CustomFields_FloatCustomFieldConfig_Fragment | CustomFields_BooleanCustomFieldConfig_Fragment | CustomFields_DateTimeCustomFieldConfig_Fragment | CustomFields_RelationCustomFieldConfig_Fragment;
 
@@ -8682,6 +8755,13 @@ export namespace GetProductWithVariants {
   export type Product = (NonNullable<GetProductWithVariantsQuery['product']>);
 }
 
+export namespace GetProductSimple {
+  export type Variables = GetProductSimpleQueryVariables;
+  export type Query = GetProductSimpleQuery;
+  export type Product = (NonNullable<GetProductSimpleQuery['product']>);
+  export type FeaturedAsset = (NonNullable<(NonNullable<GetProductSimpleQuery['product']>)['featuredAsset']>);
+}
+
 export namespace GetProductList {
   export type Variables = GetProductListQueryVariables;
   export type Query = GetProductListQuery;
@@ -8815,9 +8895,23 @@ export namespace GetProductVariant {
   export type Variables = GetProductVariantQueryVariables;
   export type Query = GetProductVariantQuery;
   export type ProductVariant = (NonNullable<GetProductVariantQuery['productVariant']>);
+  export type FeaturedAsset = (NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['featuredAsset']>);
+  export type FocalPoint = (NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['featuredAsset']>)['focalPoint']>);
   export type Product = (NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>);
-  export type FeaturedAsset = (NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>);
-  export type FocalPoint = (NonNullable<(NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>)['focalPoint']>);
+  export type _FeaturedAsset = (NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>);
+  export type _FocalPoint = (NonNullable<(NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>)['focalPoint']>);
+}
+
+export namespace GetProductVariantList {
+  export type Variables = GetProductVariantListQueryVariables;
+  export type Query = GetProductVariantListQuery;
+  export type ProductVariants = (NonNullable<GetProductVariantListQuery['productVariants']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>;
+  export type FeaturedAsset = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['featuredAsset']>);
+  export type FocalPoint = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['featuredAsset']>)['focalPoint']>);
+  export type Product = (NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>);
+  export type _FeaturedAsset = (NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>);
+  export type _FocalPoint = (NonNullable<(NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetProductVariantListQuery['productVariants']>)['items']>)[number]>['product']>)['featuredAsset']>)['focalPoint']>);
 }
 
 export namespace GetTagList {
@@ -9176,6 +9270,10 @@ export namespace DateTimeCustomField {
   export type Fragment = DateTimeCustomFieldFragment;
 }
 
+export namespace RelationCustomField {
+  export type Fragment = RelationCustomFieldFragment;
+}
+
 export namespace CustomFields {
   export type Fragment = CustomFieldsFragment;
   export type StringCustomFieldConfigInlineFragment = (DiscriminateUnion<CustomFieldsFragment, { __typename?: 'StringCustomFieldConfig' }>);
@@ -9184,6 +9282,7 @@ export namespace CustomFields {
   export type IntCustomFieldConfigInlineFragment = (DiscriminateUnion<CustomFieldsFragment, { __typename?: 'IntCustomFieldConfig' }>);
   export type FloatCustomFieldConfigInlineFragment = (DiscriminateUnion<CustomFieldsFragment, { __typename?: 'FloatCustomFieldConfig' }>);
   export type DateTimeCustomFieldConfigInlineFragment = (DiscriminateUnion<CustomFieldsFragment, { __typename?: 'DateTimeCustomFieldConfig' }>);
+  export type RelationCustomFieldConfigInlineFragment = (DiscriminateUnion<CustomFieldsFragment, { __typename?: 'RelationCustomFieldConfig' }>);
 }
 
 export namespace GetServerConfig {

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -98,6 +98,8 @@ export function getDefaultConfigArgSingleValue(type: ConfigArgType | CustomField
             return '';
         case 'datetime':
             return new Date();
+        case 'relation':
+            return null;
         default:
             assertNever(type);
     }

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.ts

@@ -71,6 +71,8 @@ function getDefaultValue(type: CustomFieldType): any {
             return 0;
         case 'datetime':
             return new Date();
+        case 'relation':
+            return null;
         default:
             assertNever(type);
     }

+ 53 - 0
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -321,6 +321,19 @@ export const GET_PRODUCT_WITH_VARIANTS = gql`
     ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;
 
+export const GET_PRODUCT_SIMPLE = gql`
+    query GetProductSimple($id: ID!) {
+        product(id: $id) {
+            id
+            name
+            featuredAsset {
+                ...Asset
+            }
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;
+
 export const GET_PRODUCT_LIST = gql`
     query GetProductList($options: ProductListOptions) {
         products(options: $options) {
@@ -624,6 +637,14 @@ export const GET_PRODUCT_VARIANT = gql`
             id
             name
             sku
+            featuredAsset {
+                id
+                preview
+                focalPoint {
+                    x
+                    y
+                }
+            }
             product {
                 id
                 featuredAsset {
@@ -639,6 +660,38 @@ export const GET_PRODUCT_VARIANT = gql`
     }
 `;
 
+export const GET_PRODUCT_VARIANT_LIST = gql`
+    query GetProductVariantList($options: ProductVariantListOptions!) {
+        productVariants(options: $options) {
+            items {
+                id
+                name
+                sku
+                featuredAsset {
+                    id
+                    preview
+                    focalPoint {
+                        x
+                        y
+                    }
+                }
+                product {
+                    id
+                    featuredAsset {
+                        id
+                        preview
+                        focalPoint {
+                            x
+                            y
+                        }
+                    }
+                }
+            }
+            totalItems
+        }
+    }
+`;
+
 export const GET_TAG_LIST = gql`
     query GetTagList($options: TagListOptions) {
         tags(options: $options) {

+ 12 - 0
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -527,6 +527,14 @@ export const DATE_TIME_CUSTOM_FIELD_FRAGMENT = gql`
     }
     ${CUSTOM_FIELD_CONFIG_FRAGMENT}
 `;
+export const RELATION_CUSTOM_FIELD_FRAGMENT = gql`
+    fragment RelationCustomField on RelationCustomFieldConfig {
+        ...CustomFieldConfig
+        entity
+        scalarFields
+    }
+    ${CUSTOM_FIELD_CONFIG_FRAGMENT}
+`;
 
 export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
     fragment CustomFields on CustomField {
@@ -548,6 +556,9 @@ export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
         ... on DateTimeCustomFieldConfig {
             ...DateTimeCustomField
         }
+        ... on RelationCustomFieldConfig {
+            ...RelationCustomField
+        }
     }
     ${STRING_CUSTOM_FIELD_FRAGMENT}
     ${LOCALE_STRING_CUSTOM_FIELD_FRAGMENT}
@@ -555,6 +566,7 @@ export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
     ${INT_CUSTOM_FIELD_FRAGMENT}
     ${FLOAT_CUSTOM_FIELD_FRAGMENT}
     ${DATE_TIME_CUSTOM_FIELD_FRAGMENT}
+    ${RELATION_CUSTOM_FIELD_FRAGMENT}
 `;
 
 export const GET_SERVER_CONFIG = gql`

+ 8 - 3
packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts

@@ -1,6 +1,7 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { DataProxy, MutationUpdaterFn, WatchQueryFetchPolicy } from '@apollo/client/core';
+import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
 import { Apollo } from 'apollo-angular';
 import { DocumentNode } from 'graphql/language/ast';
 import { Observable } from 'rxjs';
@@ -15,6 +16,7 @@ import {
     isEntityCreateOrUpdateMutation,
     removeReadonlyCustomFields,
 } from '../utils/remove-readonly-custom-fields';
+import { transformRelationCustomFieldInputs } from '../utils/transform-relation-custom-field-inputs';
 
 @Injectable()
 export class BaseDataService {
@@ -56,7 +58,7 @@ export class BaseDataService {
         update?: MutationUpdaterFn<T>,
     ): Observable<T> {
         const withCustomFields = addCustomFields(mutation, this.customFields);
-        const withoutReadonlyFields = this.removeReadonlyCustomFieldsFromVariables(mutation, variables);
+        const withoutReadonlyFields = this.prepareCustomFields(mutation, variables);
 
         return this.apollo
             .mutate<T, V>({
@@ -67,12 +69,15 @@ export class BaseDataService {
             .pipe(map(result => result.data as T));
     }
 
-    private removeReadonlyCustomFieldsFromVariables<V>(mutation: DocumentNode, variables: V): V {
+    private prepareCustomFields<V>(mutation: DocumentNode, variables: V): V {
         const entity = isEntityCreateOrUpdateMutation(mutation);
         if (entity) {
             const customFieldConfig = this.customFields[entity];
             if (variables && customFieldConfig) {
-                return removeReadonlyCustomFields(variables, customFieldConfig);
+                let variablesClone = simpleDeepClone(variables as any);
+                variablesClone = removeReadonlyCustomFields(variablesClone, customFieldConfig);
+                variablesClone = transformRelationCustomFieldInputs(variablesClone, customFieldConfig);
+                return variablesClone;
             }
         }
         return variables;

+ 21 - 0
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -26,13 +26,16 @@ import {
     GetProductList,
     GetProductOptionGroup,
     GetProductOptionGroups,
+    GetProductSimple,
     GetProductVariant,
+    GetProductVariantList,
     GetProductVariantOptions,
     GetProductWithVariants,
     GetTag,
     GetTagList,
     ProductListOptions,
     ProductSelectorSearch,
+    ProductVariantListOptions,
     Reindex,
     RemoveOptionGroupFromProduct,
     RemoveProductsFromChannel,
@@ -72,7 +75,9 @@ import {
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUP,
     GET_PRODUCT_OPTION_GROUPS,
+    GET_PRODUCT_SIMPLE,
     GET_PRODUCT_VARIANT,
+    GET_PRODUCT_VARIANT_LIST,
     GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
     GET_TAG,
@@ -135,6 +140,22 @@ export class ProductDataService {
         );
     }
 
+    getProductSimple(id: string) {
+        return this.baseDataService.query<GetProductSimple.Query, GetProductSimple.Variables>(
+            GET_PRODUCT_SIMPLE,
+            {
+                id,
+            },
+        );
+    }
+
+    getProductVariants(options: ProductVariantListOptions) {
+        return this.baseDataService.query<GetProductVariantList.Query, GetProductVariantList.Variables>(
+            GET_PRODUCT_VARIANT_LIST,
+            { options },
+        );
+    }
+
     getProductVariant(id: string) {
         return this.baseDataService.query<GetProductVariant.Query, GetProductVariant.Variables>(
             GET_PRODUCT_VARIANT,

+ 16 - 1
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -7,7 +7,7 @@ import {
     SelectionNode,
 } from 'graphql';
 
-import { CustomFields } from '../../common/generated-types';
+import { CustomFields, RelationCustomField, RelationCustomFieldFragment } from '../../common/generated-types';
 
 /**
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
@@ -45,6 +45,21 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
                                 kind: Kind.NAME,
                                 value: customField.name,
                             },
+                            // For "relation" custom fields, we need to also select
+                            // all the scalar fields of the related type
+                            ...(customField.type === 'relation'
+                                ? {
+                                      selectionSet: {
+                                          kind: Kind.SELECTION_SET,
+                                          selections: (customField as RelationCustomFieldFragment).scalarFields.map(
+                                              f => ({
+                                                  kind: Kind.FIELD,
+                                                  name: { kind: Kind.NAME, value: f },
+                                              }),
+                                          ),
+                                      },
+                                  }
+                                : {}),
                         } as FieldNode;
                     }),
                 },

+ 4 - 5
packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts

@@ -43,15 +43,14 @@ function extractInputType(type: TypeNode): NamedTypeNode {
  * Removes any `readonly` custom fields from an entity (including its translations).
  * To be used before submitting the entity for a create or update request.
  */
-export function removeReadonlyCustomFields<T extends any = any>(
+export function removeReadonlyCustomFields<T extends { input?: any } & Record<string, any> = any>(
     variables: T,
     customFieldConfig: CustomFieldConfig[],
 ): T {
-    const clone = simpleDeepClone(variables as any);
-    if (clone.input) {
-        removeReadonly(clone.input, customFieldConfig);
+    if (variables.input) {
+        removeReadonly(variables.input, customFieldConfig);
     }
-    return removeReadonly(clone, customFieldConfig);
+    return removeReadonly(variables, customFieldConfig);
 }
 
 function removeReadonly(input: any, customFieldConfig: CustomFieldConfig[]) {

+ 91 - 0
packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.spec.ts

@@ -0,0 +1,91 @@
+import { CustomFieldConfig } from '../../common/generated-types';
+
+import { transformRelationCustomFieldInputs } from './transform-relation-custom-field-inputs';
+
+describe('transformRelationCustomFieldInput()', () => {
+    it('transforms single type', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'weight', type: 'int', list: false },
+            { name: 'avatar', type: 'relation', list: false, entity: 'Asset' },
+        ];
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+                avatar: {
+                    id: 123,
+                    preview: '...',
+                },
+            },
+        };
+
+        const result = transformRelationCustomFieldInputs(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+                avatarId: 123,
+            },
+        } as any);
+    });
+
+    it('transforms single type with null value', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'avatar', type: 'relation', list: false, entity: 'Asset' },
+        ];
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: {
+                avatar: null,
+            },
+        };
+
+        const result = transformRelationCustomFieldInputs(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: { avatarId: null },
+        } as any);
+    });
+
+    it('transforms list type', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'weight', type: 'int', list: false },
+            { name: 'avatars', type: 'relation', list: true, entity: 'Asset' },
+        ];
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+                avatars: [
+                    {
+                        id: 123,
+                        preview: '...',
+                    },
+                    {
+                        id: 456,
+                        preview: '...',
+                    },
+                    {
+                        id: 789,
+                        preview: '...',
+                    },
+                ],
+            },
+        };
+
+        const result = transformRelationCustomFieldInputs(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+                avatarsIds: [123, 456, 789],
+            },
+        } as any);
+    });
+});

+ 47 - 0
packages/admin-ui/src/lib/core/src/data/utils/transform-relation-custom-field-inputs.ts

@@ -0,0 +1,47 @@
+import { getGraphQlInputName } from '@vendure/common/lib/shared-utils';
+import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
+
+import { CustomFieldConfig } from '../../common/generated-types';
+
+/**
+ * Removes any `readonly` custom fields from an entity (including its translations).
+ * To be used before submitting the entity for a create or update request.
+ */
+export function transformRelationCustomFieldInputs<T extends { input?: any } & Record<string, any> = any>(
+    variables: T,
+    customFieldConfig: CustomFieldConfig[],
+): T {
+    if (variables.input) {
+        transformRelations(variables.input, customFieldConfig);
+    }
+    return transformRelations(variables, customFieldConfig);
+}
+
+/**
+ * @description
+ * When persisting custom fields, we need to send just the IDs of the relations,
+ * rather than the objects themselves.
+ */
+function transformRelations(input: any, customFieldConfig: CustomFieldConfig[]) {
+    for (const field of customFieldConfig) {
+        if (field.type === 'relation') {
+            if (hasCustomFields(input)) {
+                const entityValue = input.customFields[field.name];
+                if (input.customFields.hasOwnProperty(field.name)) {
+                    delete input.customFields[field.name];
+                    input.customFields[getGraphQlInputName(field)] =
+                        field.list && Array.isArray(entityValue)
+                            ? entityValue.map(v => v?.id)
+                            : entityValue === null
+                            ? null
+                            : entityValue?.id;
+                }
+            }
+        }
+    }
+    return input;
+}
+
+function hasCustomFields(input: any): input is { customFields: { [key: string]: any } } {
+    return input != null && input.hasOwnProperty('customFields');
+}

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html

@@ -18,7 +18,7 @@
 ></vdr-asset-search-input>
 <vdr-asset-gallery
     [assets]="(assets$ | async)! | paginate: paginationConfig"
-    [multiSelect]="true"
+    [multiSelect]="multiSelect"
     (selectionChange)="selection = $event"
 ></vdr-asset-gallery>
 

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts

@@ -34,6 +34,8 @@ export class AssetPickerDialogComponent implements OnInit, OnDestroy, Dialog<Ass
         totalItems: 1,
     };
 
+    multiSelect = true;
+
     resolveWith: (result?: Asset[]) => void;
     selection: Asset[] = [];
     searchTerm$ = new BehaviorSubject<string | undefined>(undefined);

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts

@@ -199,7 +199,7 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
             })
             .subscribe(result => {
                 if (result) {
-                    this.notificationService.success(_('notify.updated-tags-success'));
+                    this.notificationService.success(_('common.notify-updated-tags-success'));
                 }
             });
     }

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -309,6 +309,8 @@ export class DynamicFormInputComponent
                 return { component: 'date-form-input' };
             case 'ID':
                 return { component: 'text-form-input' };
+            case 'relation':
+                return { component: 'relation-form-input' };
             default:
                 assertNever(type);
         }

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts

@@ -16,6 +16,7 @@ import { FacetValueFormInputComponent } from './facet-value-form-input/facet-val
 import { NumberFormInputComponent } from './number-form-input/number-form-input.component';
 import { PasswordFormInputComponent } from './password-form-input/password-form-input.component';
 import { ProductSelectorFormInputComponent } from './product-selector-form-input/product-selector-form-input.component';
+import { RelationFormInputComponent } from './relation-form-input/relation-form-input.component';
 import { SelectFormInputComponent } from './select-form-input/select-form-input.component';
 import { TextFormInputComponent } from './text-form-input/text-form-input.component';
 
@@ -30,6 +31,7 @@ export const defaultFormInputs = [
     ProductSelectorFormInputComponent,
     CustomerGroupFormInputComponent,
     PasswordFormInputComponent,
+    RelationFormInputComponent,
 ];
 
 /**

+ 23 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.html

@@ -0,0 +1,23 @@
+<vdr-relation-card
+    (select)="selectAsset()"
+    (remove)="remove()"
+    placeholderIcon="image"
+    [entity]="asset$ | async"
+    [selectLabel]="'asset.select-asset' | translate"
+    [removable]="!config.list"
+    [readonly]="readonly"
+>
+    <ng-template vdrRelationCardPreview let-asset="entity">
+        <img
+            class="preview"
+            [title]="'asset.preview' | translate"
+            [src]="asset | assetPreview: 'tiny'"
+            (click)="previewAsset(asset)"
+        />
+    </ng-template>
+    <ng-template vdrRelationCardDetail let-asset="entity">
+        <div class="name" [title]="asset.name">
+            {{ asset.name }}
+        </div>
+    </ng-template>
+</vdr-relation-card>

+ 14 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.scss

@@ -0,0 +1,14 @@
+.preview {
+    cursor: pointer;
+}
+
+.detail {
+    flex: 1;
+    overflow: hidden;
+}
+
+.name {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}

+ 71 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component.ts

@@ -0,0 +1,71 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { Observable, of } from 'rxjs';
+import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
+
+import { GetAsset, RelationCustomFieldConfig } from '../../../../common/generated-types';
+import { DataService } from '../../../../data/providers/data.service';
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { AssetPickerDialogComponent } from '../../../components/asset-picker-dialog/asset-picker-dialog.component';
+import { AssetPreviewDialogComponent } from '../../../components/asset-preview-dialog/asset-preview-dialog.component';
+
+@Component({
+    selector: 'vdr-relation-asset-input',
+    templateUrl: './relation-asset-input.component.html',
+    styleUrls: ['./relation-asset-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationAssetInputComponent implements OnInit {
+    @Input() readonly: boolean;
+    @Input() parentFormControl: FormControl;
+    @Input() config: RelationCustomFieldConfig;
+    asset$: Observable<GetAsset.Asset | undefined>;
+
+    constructor(private modalService: ModalService, private dataService: DataService) {}
+
+    ngOnInit() {
+        this.asset$ = this.parentFormControl.valueChanges.pipe(
+            startWith(this.parentFormControl.value),
+            map(asset => asset?.id),
+            distinctUntilChanged(),
+            switchMap(id => {
+                if (id) {
+                    return this.dataService.product.getAsset(id).mapStream(data => data.asset || undefined);
+                } else {
+                    return of(undefined);
+                }
+            }),
+        );
+    }
+
+    selectAsset() {
+        this.modalService
+            .fromComponent(AssetPickerDialogComponent, {
+                size: 'xl',
+                locals: {
+                    multiSelect: false,
+                },
+            })
+            .subscribe(result => {
+                if (result && result.length) {
+                    this.parentFormControl.setValue(result[0]);
+                    this.parentFormControl.markAsDirty();
+                }
+            });
+    }
+
+    remove() {
+        this.parentFormControl.setValue(null);
+        this.parentFormControl.markAsDirty();
+    }
+
+    previewAsset(asset: GetAsset.Asset) {
+        this.modalService
+            .fromComponent(AssetPreviewDialogComponent, {
+                size: 'xl',
+                closable: true,
+                locals: { asset },
+            })
+            .subscribe();
+    }
+}

+ 30 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.html

@@ -0,0 +1,30 @@
+<vdr-relation-card
+    (select)="selectCustomer()"
+    (remove)="remove()"
+    placeholderIcon="user"
+    [entity]="customer"
+    [selectLabel]="'customer.select-customer' | translate"
+    [removable]="!config.list"
+    [readonly]="readonly"
+>
+    <ng-template vdrRelationCardPreview>
+        <div class="placeholder">
+            <clr-icon shape="user" class="is-solid" size="50"></clr-icon>
+        </div>
+    </ng-template>
+    <ng-template vdrRelationCardDetail let-c="entity">
+        <div class="">
+            <a [routerLink]="['/customer/customers', c.id]">{{ c.firstName }} {{ c.lastName }}</a>
+        </div>
+        <div class="">{{ c.emailAddress }}</div>
+    </ng-template>
+</vdr-relation-card>
+
+<ng-template #selector let-select="select">
+    <ng-select [items]="results$ | async" [typeahead]="searchTerm$" appendTo="body" (change)="select($event)">
+        <ng-template ng-option-tmp let-item="item">
+            <b>{{ item.emailAddress }}</b>
+            {{ item.firstName }} {{ item.lastName }}
+        </ng-template>
+    </ng-select>
+</ng-template>

+ 0 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.scss


+ 74 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component.ts

@@ -0,0 +1,74 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Observable, of, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
+
+import {
+    CustomerFragment,
+    GetCustomerList,
+    GetProductList,
+    GetProductSimple,
+    RelationCustomFieldConfig,
+} from '../../../../common/generated-types';
+import { DataService } from '../../../../data/providers/data.service';
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { RelationSelectorDialogComponent } from '../relation-selector-dialog/relation-selector-dialog.component';
+
+@Component({
+    selector: 'vdr-relation-customer-input',
+    templateUrl: './relation-customer-input.component.html',
+    styleUrls: ['./relation-customer-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationCustomerInputComponent implements OnInit {
+    @Input() readonly: boolean;
+    @Input() parentFormControl: FormControl;
+    @Input() config: RelationCustomFieldConfig;
+
+    @ViewChild('selector') template: TemplateRef<any>;
+
+    searchControl = new FormControl('');
+    searchTerm$ = new Subject<string>();
+    results$: Observable<GetCustomerList.Items[]>;
+
+    get customer(): CustomerFragment | undefined {
+        return this.parentFormControl.value;
+    }
+
+    constructor(private modalService: ModalService, private dataService: DataService) {}
+
+    ngOnInit() {
+        this.results$ = this.searchTerm$.pipe(
+            debounceTime(200),
+            switchMap(term => {
+                return this.dataService.customer
+                    .getCustomerList(10, 0, term)
+                    .mapSingle(data => data.customers.items);
+            }),
+        );
+    }
+
+    selectCustomer() {
+        this.modalService
+            .fromComponent(RelationSelectorDialogComponent, {
+                size: 'md',
+                closable: true,
+                locals: {
+                    title: _('customer.select-customer'),
+                    selectorTemplate: this.template,
+                },
+            })
+            .subscribe(result => {
+                if (result) {
+                    this.parentFormControl.setValue(result);
+                    this.parentFormControl.markAsDirty();
+                }
+            });
+    }
+
+    remove() {
+        this.parentFormControl.setValue(null);
+        this.parentFormControl.markAsDirty();
+    }
+}

+ 37 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.html

@@ -0,0 +1,37 @@
+<vdr-relation-card
+    (select)="selectProductVariant()"
+    (remove)="remove()"
+    placeholderIcon="library"
+    [entity]="productVariant$ | async"
+    [selectLabel]="'catalog.select-product-variant' | translate"
+    [removable]="!config.list"
+    [readonly]="readonly"
+>
+    <ng-template vdrRelationCardPreview let-variant="entity">
+        <img
+            *ngIf="variant.featuredAsset || variant.product.featuredAsset as asset; else placeholder"
+            [src]="asset | assetPreview: 'tiny'"
+        />
+        <ng-template #placeholder>
+            <div class="placeholder" *ngIf="!variant.featuredAsset">
+                <clr-icon shape="image" size="50"></clr-icon>
+            </div>
+        </ng-template>
+    </ng-template>
+    <ng-template vdrRelationCardDetail let-variant="entity">
+        <a [routerLink]="['/catalog/products', variant.product.id, { tab: 'variants' }]">{{ variant.name }}</a>
+        <div class="">{{ variant.sku }}</div>
+    </ng-template>
+</vdr-relation-card>
+
+<ng-template #selector let-select="select">
+    <ng-select [items]="results$ | async" [typeahead]="searchTerm$" appendTo="body" (change)="select($event)">
+        <ng-template ng-option-tmp let-item="item">
+            <img
+                *ngIf="item.featuredAsset || item.product.featuredAsset as asset"
+                [src]="asset | assetPreview: 32"
+            />
+            {{ item.name }}
+        </ng-template>
+    </ng-select>
+</ng-template>

+ 3 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.scss

@@ -0,0 +1,3 @@
+.placeholder {
+    color: var(--color-grey-300);
+}

+ 95 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component.ts

@@ -0,0 +1,95 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Observable, of, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
+
+import {
+    GetProductVariant,
+    GetProductVariantList,
+    RelationCustomFieldConfig,
+} from '../../../../common/generated-types';
+import { DataService } from '../../../../data/providers/data.service';
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { RelationSelectorDialogComponent } from '../relation-selector-dialog/relation-selector-dialog.component';
+
+@Component({
+    selector: 'vdr-relation-product-variant-input',
+    templateUrl: './relation-product-variant-input.component.html',
+    styleUrls: ['./relation-product-variant-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationProductVariantInputComponent implements OnInit {
+    @Input() readonly: boolean;
+    @Input() parentFormControl: FormControl;
+    @Input() config: RelationCustomFieldConfig;
+
+    @ViewChild('selector') template: TemplateRef<any>;
+
+    searchControl = new FormControl('');
+    searchTerm$ = new Subject<string>();
+    results$: Observable<GetProductVariantList.Items[]>;
+    productVariant$: Observable<GetProductVariant.ProductVariant | undefined>;
+
+    constructor(private modalService: ModalService, private dataService: DataService) {}
+
+    ngOnInit() {
+        this.productVariant$ = this.parentFormControl.valueChanges.pipe(
+            startWith(this.parentFormControl.value),
+            map(variant => variant?.id),
+            distinctUntilChanged(),
+            switchMap(id => {
+                if (id) {
+                    return this.dataService.product
+                        .getProductVariant(id)
+                        .mapStream(data => data.productVariant || undefined);
+                } else {
+                    return of(undefined);
+                }
+            }),
+        );
+
+        this.results$ = this.searchTerm$.pipe(
+            debounceTime(200),
+            switchMap(term => {
+                return this.dataService.product
+                    .getProductVariants({
+                        ...(term
+                            ? {
+                                  filter: {
+                                      name: {
+                                          contains: term,
+                                      },
+                                  },
+                              }
+                            : {}),
+                        take: 10,
+                    })
+                    .mapSingle(data => data.productVariants.items);
+            }),
+        );
+    }
+
+    selectProductVariant() {
+        this.modalService
+            .fromComponent(RelationSelectorDialogComponent, {
+                size: 'md',
+                closable: true,
+                locals: {
+                    title: _('catalog.select-product-variant'),
+                    selectorTemplate: this.template,
+                },
+            })
+            .subscribe(result => {
+                if (result) {
+                    this.parentFormControl.setValue(result);
+                    this.parentFormControl.markAsDirty();
+                }
+            });
+    }
+
+    remove() {
+        this.parentFormControl.setValue(null);
+        this.parentFormControl.markAsDirty();
+    }
+}

+ 28 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.html

@@ -0,0 +1,28 @@
+<vdr-relation-card
+    (select)="selectProduct()"
+    (remove)="remove()"
+    placeholderIcon="library"
+    [entity]="product$ | async"
+    [selectLabel]="'catalog.select-product' | translate"
+    [removable]="!config.list"
+    [readonly]="readonly"
+>
+    <ng-template vdrRelationCardPreview let-product="entity">
+        <img *ngIf="product.featuredAsset" [src]="product.featuredAsset | assetPreview: 'tiny'" />
+        <div class="placeholder" *ngIf="!product.featuredAsset">
+            <clr-icon shape="image" size="50"></clr-icon>
+        </div>
+    </ng-template>
+    <ng-template vdrRelationCardDetail let-product="entity">
+        <a [routerLink]="['/catalog/products', product.id]">{{ product.name }}</a>
+    </ng-template>
+</vdr-relation-card>
+
+<ng-template #selector let-select="select">
+    <ng-select [items]="results$ | async" [typeahead]="searchTerm$" appendTo="body" (change)="select($event)">
+        <ng-template ng-option-tmp let-item="item">
+            <img [src]="item.featuredAsset | assetPreview: 32" />
+            {{ item.name }}
+        </ng-template>
+    </ng-select>
+</ng-template>

+ 3 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.scss

@@ -0,0 +1,3 @@
+.placeholder {
+    color: var(--color-grey-300);
+}

+ 95 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component.ts

@@ -0,0 +1,95 @@
+import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Observable, of, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
+
+import {
+    GetProductList,
+    GetProductSimple,
+    RelationCustomFieldConfig,
+} from '../../../../common/generated-types';
+import { DataService } from '../../../../data/providers/data.service';
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { RelationSelectorDialogComponent } from '../relation-selector-dialog/relation-selector-dialog.component';
+
+@Component({
+    selector: 'vdr-relation-product-input',
+    templateUrl: './relation-product-input.component.html',
+    styleUrls: ['./relation-product-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationProductInputComponent implements OnInit {
+    @Input() readonly: boolean;
+    @Input() parentFormControl: FormControl;
+    @Input() config: RelationCustomFieldConfig;
+
+    @ViewChild('selector') template: TemplateRef<any>;
+
+    searchControl = new FormControl('');
+    searchTerm$ = new Subject<string>();
+    results$: Observable<GetProductList.Items[]>;
+    product$: Observable<GetProductSimple.Product | undefined>;
+
+    constructor(private modalService: ModalService, private dataService: DataService) {}
+
+    ngOnInit() {
+        this.product$ = this.parentFormControl.valueChanges.pipe(
+            startWith(this.parentFormControl.value),
+            map(product => product?.id),
+            distinctUntilChanged(),
+            switchMap(id => {
+                if (id) {
+                    return this.dataService.product
+                        .getProductSimple(id)
+                        .mapStream(data => data.product || undefined);
+                } else {
+                    return of(undefined);
+                }
+            }),
+        );
+
+        this.results$ = this.searchTerm$.pipe(
+            debounceTime(200),
+            switchMap(term => {
+                return this.dataService.product
+                    .getProducts({
+                        ...(term
+                            ? {
+                                  filter: {
+                                      name: {
+                                          contains: term,
+                                      },
+                                  },
+                              }
+                            : {}),
+                        take: 10,
+                    })
+                    .mapSingle(data => data.products.items);
+            }),
+        );
+    }
+
+    selectProduct() {
+        this.modalService
+            .fromComponent(RelationSelectorDialogComponent, {
+                size: 'md',
+                closable: true,
+                locals: {
+                    title: _('catalog.select-product'),
+                    selectorTemplate: this.template,
+                },
+            })
+            .subscribe(result => {
+                if (result) {
+                    this.parentFormControl.setValue(result);
+                    this.parentFormControl.markAsDirty();
+                }
+            });
+    }
+
+    remove() {
+        this.parentFormControl.setValue(null);
+        this.parentFormControl.markAsDirty();
+    }
+}

+ 31 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.html

@@ -0,0 +1,31 @@
+<div class="flex">
+    <ng-container *ngIf="entity; else placeholder">
+        <div class="preview">
+            <ng-container *ngTemplateOutlet="previewTemplate; context: { entity: entity }"></ng-container>
+        </div>
+        <div class="detail">
+            <div class="pl3">
+                <ng-container *ngTemplateOutlet="detailTemplate; context: { entity: entity }"></ng-container>
+            </div>
+            <button *ngIf="!readonly" class="btn btn-sm btn-link" (click)="select.emit()">
+                <clr-icon shape="link"></clr-icon> {{ 'common.change-selection' | translate }}
+            </button>
+            <button *ngIf="!readonly && removable" class="btn btn-sm btn-link" (click)="remove.emit()">
+                <clr-icon shape="times"></clr-icon> {{ 'common.remove' | translate }}
+            </button>
+        </div>
+    </ng-container>
+    <ng-template #placeholder>
+        <div class="preview">
+            <div class="placeholder" (click)="!readonly && select.emit()">
+                <clr-icon [attr.shape]="placeholderIcon" size="50"></clr-icon>
+            </div>
+        </div>
+        <div class="detail">
+            <div class="pl3 not-set">{{ 'common.not-set' | translate }}</div>
+            <button *ngIf="!readonly" class="btn btn-sm btn-link" (click)="select.emit()">
+                <clr-icon shape="link"></clr-icon> {{ selectLabel }}
+            </button>
+        </div>
+    </ng-template>
+</div>

+ 23 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.scss

@@ -0,0 +1,23 @@
+:host {
+    display: block;
+    min-width: 300px;
+}
+
+.placeholder {
+    color: var(--color-grey-300);
+}
+
+.not-set {
+    color: var(--color-grey-300);
+}
+
+.detail {
+    flex: 1;
+    overflow: hidden;
+}
+
+.name {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}

+ 39 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component.ts

@@ -0,0 +1,39 @@
+import { EventEmitter } from '@angular/core';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    ContentChild,
+    Directive,
+    Input,
+    Output,
+    TemplateRef,
+} from '@angular/core';
+
+@Directive({
+    selector: '[vdrRelationCardPreview]',
+})
+export class RelationCardPreviewDirective {}
+@Directive({
+    selector: '[vdrRelationCardDetail]',
+})
+export class RelationCardDetailDirective {}
+
+@Component({
+    selector: 'vdr-relation-card',
+    templateUrl: './relation-card.component.html',
+    styleUrls: ['./relation-card.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationCardComponent {
+    @Input() entity: any;
+    @Input() placeholderIcon: string;
+    @Input() selectLabel: string;
+    @Input() readonly: boolean;
+    @Input() removable = true;
+    @Output() select = new EventEmitter();
+    @Output() remove = new EventEmitter();
+    @ContentChild(RelationCardPreviewDirective, { read: TemplateRef })
+    previewTemplate: TemplateRef<any>;
+    @ContentChild(RelationCardDetailDirective, { read: TemplateRef })
+    detailTemplate: TemplateRef<any>;
+}

+ 29 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.html

@@ -0,0 +1,29 @@
+<div [ngSwitch]="config.entity">
+    <vdr-relation-asset-input
+        *ngSwitchCase="'Asset'"
+        [parentFormControl]="formControl"
+        [config]="config"
+        [readonly]="readonly"
+    ></vdr-relation-asset-input>
+    <vdr-relation-product-input
+        *ngSwitchCase="'Product'"
+        [parentFormControl]="formControl"
+        [config]="config"
+        [readonly]="readonly"
+    ></vdr-relation-product-input>
+    <vdr-relation-customer-input
+        *ngSwitchCase="'Customer'"
+        [parentFormControl]="formControl"
+        [config]="config"
+        [readonly]="readonly"
+    ></vdr-relation-customer-input>
+    <vdr-relation-product-variant-input
+        *ngSwitchCase="'ProductVariant'"
+        [parentFormControl]="formControl"
+        [config]="config"
+        [readonly]="readonly"
+    ></vdr-relation-product-variant-input>
+    <ng-template ngSwitchDefault>
+        No input component configured for "{{ config.entity }}" type
+    </ng-template>
+</div>

+ 5 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.scss

@@ -0,0 +1,5 @@
+:host {
+    display: block;
+    background-color: var(--color-component-bg-200);
+    padding: 3px;
+}

+ 19 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.ts

@@ -0,0 +1,19 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+import { RelationCustomFieldConfig } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-relation-form-input',
+    templateUrl: './relation-form-input.component.html',
+    styleUrls: ['./relation-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationFormInputComponent implements FormInputComponent {
+    static readonly id: DefaultFormComponentId = 'relation-form-input';
+    @Input() readonly: boolean;
+    formControl: FormControl;
+    config: RelationCustomFieldConfig;
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.html

@@ -0,0 +1,2 @@
+<ng-template vdrDialogTitle>{{ title | translate }}</ng-template>
+<ng-container [ngTemplateOutlet]="selectorTemplate" [ngTemplateOutletContext]="{ select: resolveWith }"></ng-container>

+ 0 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.scss


+ 15 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component.ts

@@ -0,0 +1,15 @@
+import { ChangeDetectionStrategy, Component, TemplateRef } from '@angular/core';
+
+import { Dialog } from '../../../../providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-relation-selector-dialog',
+    templateUrl: './relation-selector-dialog.component.html',
+    styleUrls: ['./relation-selector-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationSelectorDialogComponent implements Dialog<string[]> {
+    resolveWith: (result?: string[]) => void;
+    title: string;
+    selectorTemplate: TemplateRef<any>;
+}

+ 26 - 1
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -90,6 +90,17 @@ import { FacetValueFormInputComponent } from './dynamic-form-inputs/facet-value-
 import { NumberFormInputComponent } from './dynamic-form-inputs/number-form-input/number-form-input.component';
 import { PasswordFormInputComponent } from './dynamic-form-inputs/password-form-input/password-form-input.component';
 import { ProductSelectorFormInputComponent } from './dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
+import { RelationAssetInputComponent } from './dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';
+import { RelationCustomerInputComponent } from './dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component';
+import { RelationProductVariantInputComponent } from './dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component';
+import { RelationProductInputComponent } from './dynamic-form-inputs/relation-form-input/product/relation-product-input.component';
+import {
+    RelationCardComponent,
+    RelationCardDetailDirective,
+    RelationCardPreviewDirective,
+} from './dynamic-form-inputs/relation-form-input/relation-card/relation-card.component';
+import { RelationFormInputComponent } from './dynamic-form-inputs/relation-form-input/relation-form-input.component';
+import { RelationSelectorDialogComponent } from './dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
 import { SelectFormInputComponent } from './dynamic-form-inputs/select-form-input/select-form-input.component';
 import { TextFormInputComponent } from './dynamic-form-inputs/text-form-input/text-form-input.component';
 import { AssetPreviewPipe } from './pipes/asset-preview.pipe';
@@ -214,12 +225,26 @@ const DYNAMIC_FORM_INPUTS = [
     SelectFormInputComponent,
     FacetValueFormInputComponent,
     DynamicFormInputComponent,
+    RelationFormInputComponent,
+    RelationAssetInputComponent,
+    RelationProductInputComponent,
+    RelationProductVariantInputComponent,
+    RelationCustomerInputComponent,
+    RelationCardPreviewDirective,
+    RelationCardDetailDirective,
+    RelationSelectorDialogComponent,
 ];
 
 @NgModule({
     imports: [IMPORTS],
     exports: [...IMPORTS, ...DECLARATIONS, ...DYNAMIC_FORM_INPUTS],
-    declarations: [...DECLARATIONS, ...DYNAMIC_FORM_INPUTS, ManageTagsDialogComponent],
+    declarations: [
+        ...DECLARATIONS,
+        ...DYNAMIC_FORM_INPUTS,
+        ManageTagsDialogComponent,
+        RelationSelectorDialogComponent,
+        RelationCardComponent,
+    ],
     providers: [
         // This needs to be shared, since lazy-loaded
         // modules have their own entryComponents which

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Originální velikost",
     "preview": "Náhled",
     "remove-asset": "Smazat médium",
+    "select-asset": "",
     "select-assets": "Vybrat média",
     "set-as-featured-asset": "Zvýraznit médium",
     "set-focal-point": "Nastavit ohnisko",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "Hledat výraz",
     "search-product-name-or-code": "Hledat produkt dle jména, nebo kódu",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "SKU",
     "slug": "Odkaz",
     "slug-pattern-error": "Špatný formát odkazu",
@@ -163,6 +166,7 @@
     "available-languages": "Dostupné jazyky",
     "cancel": "Zrušit",
     "cancel-navigation": "Zrušit navigaci",
+    "change-selection": "",
     "channel": "Kanál",
     "channels": "Kanály",
     "code": "Kód",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Změny uloženy",
     "notify-update-error": "Vyskytla se chyba, nebylo aktualizováno: { entity }",
     "notify-update-success": "Aktualizováno: { entity }",
+    "notify-updated-tags-success": "",
     "open": "Otevřít",
     "password": "Heslo",
     "price": "Cena",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "Odebrán: {customerCount, plural, one {1 zákazník} other {{customerCount} zákazníci/zákazníků}} z \"{ groupName }\"",
     "remove-from-group": "Odebrat ze skupiny",
     "search-customers-by-email": "Hledat podle e-mailové adresy",
+    "select-customer": "",
     "set-as-default-billing-address": "Nastavit jako výchozí fakturační adresu",
     "set-as-default-shipping-address": "Nastavit jako výchozí dodací adresu",
     "street-line-1": "Adresa",
@@ -560,9 +566,6 @@
     "tax-rates": "Daňové sazby",
     "zones": "Zóny"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "Přidat k objednávce",
     "add-note": "Přidat poznámku",
@@ -795,4 +798,4 @@
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Dateigröße",
     "preview": "Vorschau",
     "remove-asset": "Asset entfernen",
+    "select-asset": "",
     "select-assets": "Assets auswählen",
     "set-as-featured-asset": "Als \"Featured Asset\" festlegen",
     "set-focal-point": "Fokuspunkt setzen",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "Suche nach Begriff",
     "search-product-name-or-code": "Suche nach Produktname oder -code",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "Artikelnummer",
     "slug": "Slug",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "Verfügbare Sprachen",
     "cancel": "Abbrechen",
     "cancel-navigation": "Navigation abbrechen",
+    "change-selection": "",
     "channel": "Kanal",
     "channels": "Kanäle",
     "code": "Code",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Änderungen gespeichert",
     "notify-update-error": "Ein Fehler ist aufgetreten, { entity } konnte nicht aktualisiert werden",
     "notify-update-success": "{ entity } aktualisiert",
+    "notify-updated-tags-success": "",
     "open": "Öffnen",
     "password": "Passwort",
     "price": "Preis",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
     "search-customers-by-email": "Suche nach E-Mail-Adresse",
+    "select-customer": "",
     "set-as-default-billing-address": "Als Standard-Rechnungsadresse festlegen",
     "set-as-default-shipping-address": "Als Standard-Versandadresse festlegen",
     "street-line-1": "Straße Zeile 1",
@@ -560,9 +566,6 @@
     "tax-rates": "Steuersätze",
     "zones": "Zonen"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "Notiz hinzufügen",
@@ -795,4 +798,4 @@
     "job-result": "Job-Ergebnis",
     "job-state": "Job-Status"
   }
-}
+}

+ 6 - 3
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Source size",
     "preview": "Preview",
     "remove-asset": "Remove asset",
+    "select-asset": "Select asset",
     "select-assets": "Select assets",
     "set-as-featured-asset": "Set as featured asset",
     "set-focal-point": "Set focal point",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "Search by asset name or tags",
     "search-for-term": "Search for term",
     "search-product-name-or-code": "Search by product name or code",
+    "select-product": "Select product",
+    "select-product-variant": "Select product variant",
     "sku": "SKU",
     "slug": "Slug",
     "slug-pattern-error": "Slug is invalid",
@@ -163,6 +166,7 @@
     "available-languages": "Available languages",
     "cancel": "Cancel",
     "cancel-navigation": "Cancel navigation",
+    "change-selection": "Change selection",
     "channel": "Channel",
     "channels": "Channels",
     "code": "Code",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Saved changes",
     "notify-update-error": "An error occurred, could not update { entity }",
     "notify-update-success": "Updated { entity }",
+    "notify-updated-tags-success": "Successfully updated tags",
     "open": "Open",
     "password": "Password",
     "price": "Price",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "Removed {customerCount, plural, one {1 customer} other {{customerCount} customers}} from \"{ groupName }\"",
     "remove-from-group": "Remove from this group",
     "search-customers-by-email": "Search by email address",
+    "select-customer": "Select customer",
     "set-as-default-billing-address": "Set as default billing",
     "set-as-default-shipping-address": "Set as default shipping",
     "street-line-1": "Street line 1",
@@ -560,9 +566,6 @@
     "tax-rates": "Tax Rates",
     "zones": "Zones"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "Add item to order",
     "add-note": "Add note",

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Tamaño original",
     "preview": "Vista previa",
     "remove-asset": "Eliminar archivo",
+    "select-asset": "",
     "select-assets": "Seleccionar archivos",
     "set-as-featured-asset": "Seleccionar como archivo por defecto",
     "set-focal-point": "",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "",
     "search-product-name-or-code": "Buscar por nombre o código de producto",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "SKU",
     "slug": "Slug",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "Idiomas disponibles",
     "cancel": "Cancelar",
     "cancel-navigation": "Cancelar navegación",
+    "change-selection": "",
     "channel": "Canal de ventas",
     "channels": "Canales de ventas",
     "code": "Código",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Cambios guardados",
     "notify-update-error": "Ha ocurrido un problema, imposible de actualizar{ entity }",
     "notify-update-success": "Actualizado { entity }",
+    "notify-updated-tags-success": "",
     "open": "Abrir",
     "password": "Contraseña",
     "price": "Precio",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
     "search-customers-by-email": "",
+    "select-customer": "",
     "set-as-default-billing-address": "Seleccionar como dirección de facturación por defecto",
     "set-as-default-shipping-address": "Seleccionar como dirección de envío por defecto",
     "street-line-1": "Calle linea 1",
@@ -560,9 +566,6 @@
     "tax-rates": "Tasas de impuestos",
     "zones": "Zonas"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "",
@@ -795,4 +798,4 @@
     "job-result": "Resultado",
     "job-state": "Estado"
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Taille originale",
     "preview": "Apercu",
     "remove-asset": "Retirer le fichier",
+    "select-asset": "",
     "select-assets": "Selectionner fichiers",
     "set-as-featured-asset": "Définir en tant que fichier en vedette",
     "set-focal-point": "Définir point de focale",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "Chercher le terme",
     "search-product-name-or-code": "Chercher par nom de produit ou code",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "UGS",
     "slug": "Identifiant",
     "slug-pattern-error": "Identifiant invalide",
@@ -163,6 +166,7 @@
     "available-languages": "Langues disponibles",
     "cancel": "Annuler",
     "cancel-navigation": "Annuler la navigation",
+    "change-selection": "",
     "channel": "Canal",
     "channels": "Canaux",
     "code": "Code",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Changements enregistrés",
     "notify-update-error": "Une erreur est survenue, mise à jour de { entity } échouée",
     "notify-update-success": "{ entity } mis à jour",
+    "notify-updated-tags-success": "",
     "open": "Ouvert",
     "password": "Mot de passe",
     "price": "Prix",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "Retrait {customerCount, plural, one {d'un client} other {de {customerCount} clients}} de \"{ groupName }\"",
     "remove-from-group": "Retirer de ce groupe",
     "search-customers-by-email": "Chercher par adresse email",
+    "select-customer": "",
     "set-as-default-billing-address": "Etablir en tant qu'adresse de facturation par défaut",
     "set-as-default-shipping-address": "Etablir en tant qu'adresse de livraison par défaut",
     "street-line-1": "Rue ligne 1",
@@ -560,9 +566,6 @@
     "tax-rates": "Taux de Taxe",
     "zones": "Zones"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "Ajouter note",
@@ -795,4 +798,4 @@
     "job-result": "Résultat de la tâche",
     "job-state": "Etat de la tâche"
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Rozmiar bazowy",
     "preview": "Podgląd",
     "remove-asset": "Usuń zasób",
+    "select-asset": "",
     "select-assets": "Wybierz zasób",
     "set-as-featured-asset": "Ustaw jako polecany",
     "set-focal-point": "Ustaw punk centralny",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "Szukaj frazy",
     "search-product-name-or-code": "Szukaj produktu po nazwie lub kodzie",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "SKU",
     "slug": "Slug",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "Dostępne języki",
     "cancel": "Anuluj",
     "cancel-navigation": "Anuluj nawigacje",
+    "change-selection": "",
     "channel": "Kanał",
     "channels": "Kanały",
     "code": "Kod",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Zapisano zmiany",
     "notify-update-error": "Wystąpił błąd, nie można zaktualizować { entity }",
     "notify-update-success": "Zaktualizowano { entity }",
+    "notify-updated-tags-success": "",
     "open": "Otwórz",
     "password": "Hasło",
     "price": "Cena",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
     "search-customers-by-email": "Szukaj przez email",
+    "select-customer": "",
     "set-as-default-billing-address": "Ustaw jako domyślny adres rozliczeniowy",
     "set-as-default-shipping-address": "Ustaw jako domyślny adres wysyłki",
     "street-line-1": "Ulica 1",
@@ -560,9 +566,6 @@
     "tax-rates": "Stawki podatkowe",
     "zones": ""
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "Dodaj notatke",
@@ -795,4 +798,4 @@
     "job-result": "Rezultat zlecenia",
     "job-state": "Status zlecenia"
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -12,6 +12,7 @@
     "original-asset-size": "Tamanho do arquivo",
     "preview": "Pré-visualização",
     "remove-asset": "Excluir imagens",
+    "select-asset": "",
     "select-assets": "Selecione imagens",
     "set-as-featured-asset": "Definir como imagem em destaque",
     "set-focal-point": "Definir ponto central",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "Pesquisar termo",
     "search-product-name-or-code": "Pesquisar por nome ou código do produto",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "SKU",
     "slug": "Slug",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "Idiomas disponíveis",
     "cancel": "Cancelar",
     "cancel-navigation": "Cancelar navegação",
+    "change-selection": "",
     "channel": "Canal",
     "channels": "Canais",
     "code": "Código",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "Alterações salvas",
     "notify-update-error": "Ocorreu um erro, não foi possível atualizar { entity }",
     "notify-update-success": "Atualizado { entity }",
+    "notify-updated-tags-success": "",
     "open": "Aberto",
     "password": "Senha",
     "price": "Preço",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "Excluído {customerCount, plural, one {1 customer} other {{customerCount} customers}} from \"{ groupName }\"",
     "remove-from-group": "Excluir deste grupo",
     "search-customers-by-email": "Busca por email",
+    "select-customer": "",
     "set-as-default-billing-address": "Definir como cobrança padrão",
     "set-as-default-shipping-address": "Definir como remessa padrão",
     "street-line-1": "Endereço",
@@ -560,9 +566,6 @@
     "tax-rates": "Taxas de impostos",
     "zones": "Zonas"
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "Adicionar nota",
@@ -795,4 +798,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -12,6 +12,7 @@
     "original-asset-size": "资源大小",
     "preview": "预览",
     "remove-asset": "移除资源",
+    "select-asset": "",
     "select-assets": "选择资源",
     "set-as-featured-asset": "设置为特征图片",
     "set-focal-point": "设置图片焦点",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "输入搜索条目",
     "search-product-name-or-code": "输入要搜索的商品名称或商品编码",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "商品库存编码",
     "slug": "名称缩写",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "可用语言",
     "cancel": "取消",
     "cancel-navigation": "取消",
+    "change-selection": "",
     "channel": "销售渠道",
     "channels": "销售渠道",
     "code": "编码",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "修改已保存",
     "notify-update-error": "更新{ entity }失败",
     "notify-update-success": "{ entity }已更新",
+    "notify-updated-tags-success": "",
     "open": "详情",
     "password": "密码",
     "price": "价格",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
     "search-customers-by-email": "输入要搜索的客户邮件地址",
+    "select-customer": "",
     "set-as-default-billing-address": "设置为默认账单地址",
     "set-as-default-shipping-address": "设置为默认邮寄地址",
     "street-line-1": "街道",
@@ -560,9 +566,6 @@
     "tax-rates": "税表管理",
     "zones": ""
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "添加备注",
@@ -795,4 +798,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

+ 7 - 4
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -12,6 +12,7 @@
     "original-asset-size": "檔案大小",
     "preview": "預覽",
     "remove-asset": "移除檔案",
+    "select-asset": "",
     "select-assets": "選擇檔案",
     "set-as-featured-asset": "設置為精選圖片",
     "set-focal-point": "設置圖片焦點",
@@ -131,6 +132,8 @@
     "search-asset-name-or-tag": "",
     "search-for-term": "輸入搜索條目",
     "search-product-name-or-code": "輸入要搜索的商品名稱或商品編碼",
+    "select-product": "",
+    "select-product-variant": "",
     "sku": "商品庫存編碼",
     "slug": "名稱缩写",
     "slug-pattern-error": "",
@@ -163,6 +166,7 @@
     "available-languages": "可用語言",
     "cancel": "取消",
     "cancel-navigation": "取消",
+    "change-selection": "",
     "channel": "渠道",
     "channels": "渠道",
     "code": "編碼",
@@ -210,6 +214,7 @@
     "notify-saved-changes": "修改已保存",
     "notify-update-error": "更新{ entity }失敗",
     "notify-update-success": "{ entity }已更新",
+    "notify-updated-tags-success": "",
     "open": "詳情",
     "password": "密碼",
     "price": "價格",
@@ -291,6 +296,7 @@
     "remove-customers-from-group-success": "",
     "remove-from-group": "",
     "search-customers-by-email": "輸入要搜索的客户電郵地址",
+    "select-customer": "",
     "set-as-default-billing-address": "設定為默認賬單地址",
     "set-as-default-shipping-address": "設定為默認郵寄地址",
     "street-line-1": "街道",
@@ -560,9 +566,6 @@
     "tax-rates": "税率管理",
     "zones": ""
   },
-  "notify": {
-    "updated-tags-success": ""
-  },
   "order": {
     "add-item-to-order": "",
     "add-note": "新增備注",
@@ -795,4 +798,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

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

@@ -52,10 +52,11 @@ export type Query = {
     productOptionGroups: Array<ProductOptionGroup>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     search: SearchResponse;
+    /** List Products */
     products: ProductList;
     /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
     product?: Maybe<Product>;
-    /** Get a ProductVariant by id */
+    /** List ProductVariants */
     productVariants: ProductVariantList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;

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

@@ -53,10 +53,11 @@ export type Query = {
   productOptionGroups: Array<ProductOptionGroup>;
   productOptionGroup?: Maybe<ProductOptionGroup>;
   search: SearchResponse;
+  /** List Products */
   products: ProductList;
   /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
   product?: Maybe<Product>;
-  /** Get a ProductVariant by id */
+  /** List ProductVariants */
   productVariants: ProductVariantList;
   /** Get a ProductVariant by id */
   productVariant?: Maybe<ProductVariant>;
@@ -1427,7 +1428,7 @@ export type ImportInfo = {
 /**
  * @description
  * The state of a Job in the JobQueue
- * 
+ *
  * @docsCategory common
  */
 export enum JobState {
@@ -2505,7 +2506,7 @@ export enum DeletionResult {
  * @description
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
- * 
+ *
  * @docsCategory common
  */
 export enum Permission {
@@ -2879,7 +2880,7 @@ export type CountryList = PaginatedList & {
 /**
  * @description
  * ISO 4217 currency code
- * 
+ *
  * @docsCategory common
  */
 export enum CurrencyCode {
@@ -3416,7 +3417,7 @@ export type HistoryEntryList = PaginatedList & {
  * 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 {
@@ -3805,7 +3806,7 @@ export type OrderItem = Node & {
   unitPriceWithTax: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.
-   * 
+   *
    * If Order-level discounts have been applied, this will not be the
    * actual taxable unit price (see `proratedUnitPrice`), but is generally the
    * correct price to display to customers to avoid confusion
@@ -3845,7 +3846,7 @@ export type OrderLine = Node & {
   unitPriceWithTax: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.
-   * 
+   *
    * If Order-level discounts have been applied, this will not be the
    * actual taxable unit price (see `proratedUnitPrice`), but is generally the
    * correct price to display to customers to avoid confusion

+ 2 - 1
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -52,10 +52,11 @@ export type Query = {
     productOptionGroups: Array<ProductOptionGroup>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     search: SearchResponse;
+    /** List Products */
     products: ProductList;
     /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
     product?: Maybe<Product>;
-    /** Get a ProductVariant by id */
+    /** List ProductVariants */
     productVariants: ProductVariantList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;

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

@@ -52,10 +52,11 @@ export type Query = {
     productOptionGroups: Array<ProductOptionGroup>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     search: SearchResponse;
+    /** List Products */
     products: ProductList;
     /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
     product?: Maybe<Product>;
-    /** Get a ProductVariant by id */
+    /** List ProductVariants */
     productVariants: ProductVariantList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;

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